assets/js/ — interactive components
Everything a page needs to mount one of this site’s four interactive tools, in one place. Each
tool’s own file carries the same information as a header comment; this file exists so a page
author does not have to open four files to find it. Read CONVENTIONS.md first — it is the
overall contract for content pages (front matter, layouts, CSS class vocabulary); this file only
covers the JS/data layer underneath it.
Every script here is vanilla ES5, no build step, no CDN, no framework. A page opts into a script
with scripts: [...] in its front matter (see CONVENTIONS.md §4); site.js loads on every
page regardless and needs no opt-in.
What’s in this folder
assets/js/
site.js -- always loaded (progress bar, chapter rail, nav toggle)
csv-slider.js -- generic "slider(s) -> grid lookup -> readouts" engine
(child-care valve slider, credit-collapse slider)
sortable-table.js -- generic sortable/filterable <table> engine (fifty-jurisdiction table)
jurisdiction-finder.js -- the same table's jurisdiction finder <select> (highlight + scroll + readout)
lightbox.js -- exhibits-gallery lightbox
calculator.js -- the home-page two-income calculator (COMPUTES, does not look up)
calculator.test.js -- node assets/js/calculator.test.js -- fidelity tests, run this
before trusting any number the calculator shows
lib/
worksheet.js -- ported model/worksheet.py (CJ-D 304)
net-position.js -- ported model/net_position.py (TY2026 tax model)
assets/data/
valve-units-lag.json -- figures/working/fig6_valve_units_lag.csv (130 rows)
heatmap-3child-box1.json -- figures/working/fig1_heatmap_3child_box1.csv (1,147 rows)
parenting-time-credit.json -- figures/working/fig3_credit_collapse.csv (75 rows)
states-fifty-jurisdictions.json -- data/fifty-state/tier-50-2026-09-05.json (50 rows)
All four JSON files are flat arrays of objects, keys = the source CSV’s/JSON’s own column names,
generated once and checked in (there is no build-time conversion step in this Jekyll route — see
CONVENTIONS.md §10). If a source file is ever updated, regenerate the matching JSON by hand and
recommit it; nothing regenerates these automatically.
1. The two-income calculator (home page)
This is the one tool on the site that computes rather than looks up a precomputed grid. The
design brief’s original spec called for a $5,000-step lookup grid
(heatmap-3child-box1.json, still generated and present, see note below); it cannot reproduce the
worked example ($201,000 / $29,640) to the dollar, and the project’s own rule is that no number
ships unless it’s tested against the Python it came from. So this tool instead ports
model/worksheet.py and model/net_position.py into lib/worksheet.js / lib/net-position.js
and computes the real answer for any two incomes, at any dollar value, not just the grid’s steps.
Run the test before trusting this tool, and after any change to either lib/ file:
node assets/js/calculator.test.js
338 checks: all six fact patterns from model/runs/official-xfa-vs-model-2026-09-05.txt (the
Commonwealth’s own CJ-D 304 XFA calculate-scripts), both in “round every line” mode (matches the
official scripts to the dollar on all six) and in the default unrounded mode (matches
model/worksheet.py’s own output to six decimal places — this is what the letter, the paper and
this site’s own copy quote); the worked example’s tax position from
model/runs/submission-figures-run-2026-09-05.txt to the cent; the sanity guard firing/clearing;
and (v2, 2026-09-07) the full children x custody x six-income-pair fixture grid,
fixtures/calculator-v2.json (36 rows, 0 disabled), generated straight from model/worksheet.py
and model/net_position.py in the private repo and checked to the dollar and to 0.001 on ratios.
All 338 pass as of 2026-09-07. If you change either lib/ file and a check fails, the port has
diverged from the Python — fix the port, never the test. If you add a new children/custody/income
combination the tool should support, regenerate fixtures/calculator-v2.json from the Python
first — never hand-write an expected value. The generator script that produced it was written to
/tmp (per this project’s convention that a one-off script does not belong in either repo) and is
not checked in; to reproduce it: for each of children 1/2/3 x box 1/2 x the six (higher, lower)
annual-gross pairs (201000,29640) (150000,30000) (250000,60000) (120000,80000) (300000,0)
(90000,45000), call worksheet.run(box, lower/52, higher/52, kids, 0, a_health=33.0,
b_health=43.0) then net_position.analyze(payor_gross, recip_gross, kids, r["7d"], 0.0, 0.0,
kids_under_13=0) (payor/recip resolved from r["payor"]), and write kids, box, higher, lower,
payor, order_wk (r["7d"]), line_7e (r["7e"]), true_pct_net (support_pct_of_payor_net), payor_net,
payor_after, recip_after, recip_per_person per row, plus a disabled array for any combination
that raised. All 36 combinations computed cleanly for this grid (disabled: []); if a future grid
addition can’t be computed, list it there and disable that combination in the UI instead of
guessing a number.
Markup a page must contain
<div class="tool tool-two-up" data-calculator>
<div class="tool-slider-row">
<label for="calc-higher">Higher earner, gross per year
<output id="calc-higher-output" for="calc-higher">$201,000/yr</output>
</label>
<input type="range" id="calc-higher" data-calc-input="higher"
min="60000" max="300000" step="1000" value="201000">
</div>
<div class="tool-slider-row">
<label for="calc-lower">Lower earner, gross per year
<output id="calc-lower-output" for="calc-lower">$29,640/yr</output>
</label>
<input type="range" id="calc-lower" data-calc-input="lower"
min="0" max="120000" step="120" value="29640">
</div>
<div class="tool-controls-row">
<fieldset class="segmented">
<legend>Children</legend>
<label class="segmented-option"><input type="radio" name="calc-kids" value="1" data-calc-radio="kids"> 1</label>
<label class="segmented-option"><input type="radio" name="calc-kids" value="2" data-calc-radio="kids"> 2</label>
<label class="segmented-option"><input type="radio" name="calc-kids" value="3" data-calc-radio="kids" checked> 3</label>
</fieldset>
<fieldset class="segmented">
<legend>Custody</legend>
<label class="segmented-option"><input type="radio" name="calc-custody" value="1" data-calc-radio="box" checked> Shared, equal time (Box 1)</label>
<label class="segmented-option"><input type="radio" name="calc-custody" value="2" data-calc-radio="box"> Primary with the lower earner (Box 2)</label>
</fieldset>
</div>
<div class="tool-readout" aria-live="polite">
<div>
<p class="cell-label">Weekly order (Line 7d)</p>
<p class="cell-value" data-calc-cell="order_wk">$1,013/wk</p>
</div>
<div>
<p class="cell-label">Line 7e's reading</p>
<p class="cell-value payor-line" data-calc-cell="line_7e">26.5%</p>
</div>
<div>
<p class="cell-label">True share of the payor's net income</p>
<p class="cell-value" data-calc-cell="true_pct_net">37.7%</p>
<p class="cell-note" data-calc-note="true_pct_net"> </p>
</div>
</div>
<p class="tool-flag" data-calc-flag> </p>
<details class="tool-detail" open>
<summary>After tax and the order, per year</summary>
<div class="tool-readout tool-readout--pair" aria-live="polite">
<div>
<p class="cell-label">Payor keeps</p>
<p class="cell-value" data-calc-cell="payor_after">$87,172/yr</p>
</div>
<div>
<p class="cell-label">Recipient household holds</p>
<p class="cell-value is-warning" data-calc-cell="recip_after">$92,941/yr</p>
<p class="cell-sub">Per person: <strong data-calc-cell="recip_per_person">$23,235/yr</strong></p>
<p class="cell-note visible" data-calc-note="recip_after">Above the payor</p>
</div>
</div>
<p class="tool-flag" data-calc-flag-household> </p>
</details>
<p>Three children and Box 1 (shared parenting) by default, no child care, health premiums
$33/wk (lower earner) / $43/wk (higher earner), MA under-13 credit fixed at zero. Method:
<a href="/model/worksheet.py"><code>model/worksheet.py</code></a> and
<a href="/model/net_position.py"><code>model/net_position.py</code></a>, ported and tested in
<a href="/assets/js/lib/worksheet.js"><code>assets/js/lib/worksheet.js</code></a> and
<a href="/assets/js/calculator.test.js"><code>assets/js/calculator.test.js</code></a>.</p>
</div>
(v2 introduces four new classes, all in assets/css/site.css’s “Live tools” section:
.tool-controls-row / .segmented / .segmented-option — the children/custody segmented radio
controls, grid-column: 1 / -1 so the row spans the two-up grid regardless of implicit
auto-placement — and .tool-detail — the <details> wrapper for the after-tax row, same reason.
.cell-note and .cell-sub are new too: a bold short text label paired with the warning colour
(WCAG 1.4.1 — colour is never the only channel) and the per-person figure beside a household one,
respectively. .check-yourself/.ask exist in assets/css/site.css but are built for a whole
section with an <h2> and a link grid — the method line above is one plain paragraph inside
.tool and needs no special class; a page author who wants the full check-yourself treatment
should put it in its own section outside the tool, per CONVENTIONS.md §7, not force it onto this
one line.)
Front matter: scripts: ["/assets/js/lib/worksheet.js", "/assets/js/lib/net-position.js", "/assets/js/calculator.js"]
— in that order; calculator.js reads window.MCSGWorksheet / window.MCSGNetPosition and
does nothing if either is missing (fails safe onto the static markup below, see next point).
No-JS / load-failure fallback is REQUIRED and is not automatic: every data-calc-cell span,
both sliders’ value attributes, both radio groups’ checked attributes, and the is-warning
class / note text on recip_after must already contain the real worked-example numbers exactly as
written above ($1,013/wk, 26.5%, 37.7%, $87,172/yr, $92,941/yr, $23,235/yr, $201,000/yr, $29,640/yr,
kids=3, box=1) — copy them verbatim, they are tested (calculator.test.js PART 3). A reader with
JavaScript off, or whose browser fails to load one of the two lib/ scripts, sees the worked
example stated correctly, including which of the two warning colours is on, and only loses the
ability to change it. The <details open> renders its content with no JavaScript at all; only the
ability to collapse it is progressive enhancement (the browser’s native behaviour).
Fixed facts, not sliders (per the design brief’s word budget): no child care, health premiums
$33/wk to whichever slider is currently lower and $43/wk to whichever is currently higher (matches
the convention already used by every heatmap exhibit in this project,
model/charts/_common.py’s order()), and the MA Child and Family Tax Credit for children under
13 fixed at zero qualifying children (same generic-grid convention as every heatmap on this site —
there is no third control for how many of the children are under 13). Because of that last one,
this tool’s “after tax” row runs slightly lower than the site’s own worked-example figures
elsewhere (which use two of the three children under 13): $92,941/yr here vs $93,821/yr in
model/runs/submission-figures-run-2026-09-05.txt and quoted around the rest of the site. The
method paragraph in the markup above says so; do not remove that clause if you edit the copy.
Children (1/2/3, default 3) and custody (Box 1/Box 2, default Box 1) are real controls — real
<input type="radio"> elements in a <fieldset>/<legend>, keyboard-operable, each group’s
name distinct. The two income sliders remain independent controls; if a reader drags “lower”
past “higher” the script silently swaps which value plays which role so the labels stay honest —
it does not clamp or block the drag. This calculator models Massachusetts only — the method
paragraph says so in one clause; no other jurisdiction’s worksheet is ported here (contrast the
fifty-jurisdiction table below, which is a lookup across all 51, not a computation).
heatmap-3child-box1.json is not used by this tool and is not dead weight to remove — it is
the CSV figures/working/fig1_heatmap_3child_box1.csv already committed to the repo and cited
elsewhere (e.g. exhibits E01/E02/E04 are drawn from its 1/2-child siblings), converted to JSON
in case a future page wants a client-side grid lookup (a hover tooltip over the static heatmap
exhibit, for instance). Converted and checked in now so the conversion doesn’t have to happen
twice; not wired to any control.
2. The child-care valve slider (hardship-test finding page)
Already fully built by csv-slider.js (a generic engine — read that file’s own header comment
for the complete markup contract and every data-tool-* attribute it reads). Data file:
valve-units-lag.json. No-JS fallback: the shipped site’s existing five-row table, in a
<noscript> block (design brief §3.7 item 2) — this script only enhances it, it does not
replace it.
Static-exhibit pairing: figures/exhibits/E05-what-line-7e-sees-vs-true-burden-worked-example.png.
3. The parenting-time credit-collapse tool (parenting-time finding page)
Built on the same csv-slider.js engine as the valve slider — no new script, no vendored
charting library. The design brief (§3.7 item 4) specified Observable Plot for this exhibit,
chosen for built-in per-mark ARIA labels on an SVG curve with a moving marker. That was not
built: vendoring, licensing and testing a 69KB third-party library for one exhibit was not
justified once a plain native-range-input slider (identical in kind to the valve slider, already
tested, already accessible with zero additional ARIA work because there is no SVG mark to label)
delivers the same reader-facing fact — the credit percentage at a given income disparity — from
the same CSV. What’s given up: the moving marker on a visible curve. What’s kept: every
number the brief’s spec cared about, native keyboard support, and no new dependency. Flagged here
so a reviewer can decide this was the right trade, not discover it by accident.
Data file: parenting-time-credit.json (from figures/working/fig3_credit_collapse.csv, 75
rows spanning the lower earner’s income from $2,000 to $150,000/yr against a fixed higher
earner, at which the payor’s income share of the combined household, payor_3c, ranges
57.3%–99.9%). Markup:
<div class="tool" data-tool data-tool-src="/assets/data/parenting-time-credit.json">
<div class="tool-slider-row">
<label for="credit-slider">Payor's share of the combined household income
<output id="credit-slider-output" for="credit-slider"></output>
</label>
<input type="range" id="credit-slider" data-tool-input="payor_3c"
data-tool-output-format="pct0"
data-tool-valuetext="Payor holds {value} of the combined household income"
min="0.573" max="0.999" step="0.001" value="0.877">
</div>
<div class="tool-readout" aria-live="polite">
<div>
<p class="cell-label">Box 2 order (primary custody)</p>
<p class="cell-value" data-tool-cell="box2_7d" data-tool-format="money-wk"></p>
</div>
<div>
<p class="cell-label">Box 1 order (shared parenting)</p>
<p class="cell-value" data-tool-cell="box1_7d" data-tool-format="money-wk"></p>
</div>
<div>
<p class="cell-label">The shared-parenting credit</p>
<p class="cell-value true-burden" data-tool-cell="reduction_current" data-tool-format="pct1"></p>
</div>
</div>
</div>
Default value="0.877" is the worked example’s own payor income share (87.7%), where
reduction_current = 6.9% — matches model/runs/submission-figures-run-2026-09-05.txt’s
“SECTION 5” table row 87.7% 1,088 1,013 6.9%. Front matter:
scripts: ["/assets/js/csv-slider.js"] (same file as the valve slider — a page using both tools
loads it once). No-JS fallback: render the “SECTION 5” table from the same run file as a plain
Markdown table (it already exists as prose in the project’s documents; this is not new content to
write, only to place before the slider).
Static-exhibit pairing:
figures/exhibits/E08-credit-shrinks-as-gap-widens-3-children.png (and E09, the implied-
overnight-share companion).
4. The fifty-jurisdiction sortable, searchable table (home page + its finding page)
Already fully built by sortable-table.js (generic engine — read that file’s own header comment
for the complete markup contract: <button data-sort-key="..."> headers with aria-sort, an
optional <input data-table-filter="...">). This script does not fetch or render the table —
it only reorders <tr> elements already in the DOM (design brief’s own requirement: the table
must be complete and correctly pre-sorted with no JS at all). A page author renders the 50 rows
as real HTML, from states-fifty-jurisdictions.json (or straight from
data/fifty-state/tier-50-2026-09-05.json, which this file is a byte-for-byte copy of plus one
derived field), sorted descending by s1 to match the brief’s stated no-JS default order.
Each row needs: the jurisdiction name; s1 (equal-parenting order) and s2 (primary-custody
order), both shown — this project’s finding is specifically about the GAP between a
jurisdiction’s own two numbers, so a table that only showed one would misrepresent it; and, for
Massachusetts only, the is_reader_state/.is-reader-state class so it stays visually pinned
regardless of sort order (is_massachusetts: true in the JSON marks which row). Numeric columns
need data-sort-value set to the raw number (see sortable-table.js’s own comment for why:
$4,388/mo as text would sort lexicographically, not numerically).
<table class="exhibit-table" id="fifty-table" data-sortable>
<thead>
<tr>
<th><button data-sort-key="state" aria-sort="none">Jurisdiction</button></th>
<th class="numeric"><button data-sort-key="s1" data-sort-type="number" aria-sort="descending">Equal parenting (S1)</button></th>
<th class="numeric"><button data-sort-key="s2" data-sort-type="number" aria-sort="none">Primary custody (S2)</button></th>
</tr>
</thead>
<tbody>
<tr class="is-reader-state" data-filter-text="massachusetts">
<td>Massachusetts</td>
<td class="numeric" data-sort-value="4388.48">$4,388/mo</td>
<td class="numeric" data-sort-value="4714.22">$4,714/mo</td>
</tr>
<!-- ... 49 more rows, from states-fifty-jurisdictions.json, s1 descending ... -->
</tbody>
</table>
<label for="jurisdiction-filter">Filter by jurisdiction</label>
<input type="text" id="jurisdiction-filter" data-table-filter="fifty-table" aria-controls="fifty-table">
Front matter: scripts: ["/assets/js/sortable-table.js"]. Tiering caveat is a page-copy
responsibility, not this script’s — per CONVENTIONS.md/the exhibit direction, this is a
single-fact-pattern, tiered comparison (see data/fifty-state/tier-50-2026-09-05.json’s own
per-row note field, present on Hawaii, for the one row needing one) and the surrounding prose
must say so; the table itself carries no confidence styling.
Static-exhibit pairing (the lead exhibit pair per the 2026-09-06 exhibit direction):
figures/exhibits/E12-fifty-states-lower-earner-primary-one-fact-pattern.png then
figures/exhibits/E17-ma-equal-time-vs-others-primary-custody.png.
4a. The jurisdiction finder (2026-09-07) — a separate control, next to the filter
jurisdiction-finder.js (generic-ish but built for this one table — full markup contract in its
own header comment). This is a finder, not a filter: it never hides a row. A real
<select>/<label> with 51 alphabetical options (the table’s 50 rows plus Georgia, marked
data-held-out="true" since Georgia has no row), Massachusetts preselected. Choosing an option:
- adds
.is-selected-stateto that jurisdiction’s<tr>(distinct from.is-reader-state, Massachusetts’s own permanent tint — a blue outline,--hue-recipient, so “my state” and “the state I just looked up” stay visually separate even when both land on the same row) and scrolls it into view; - writes a one-line comparison into
#jurisdiction-readoutabove the table: the jurisdiction’s own S1/S2 and its rank on each (computed client-side by sorting the table’s owndata-sort-valueattributes — no separate rank data file), next to Massachusetts’s; - for Georgia, clears any highlight and swaps the readout for the held-out sentence instead of a number.
Rank follows the table’s own numbers, not a precomputed field — this is a deliberate choice to
keep one source of truth (the rendered table) rather than a parallel dataset that could drift from
it. Works with the existing sort: the highlight is a class on the <tr> itself, and
sortable-table.js only reorders existing <tr> elements when a column header is clicked, so the
highlighted row’s class survives any re-sort (checked: select Texas, sort by S2, the highlight
follows Texas to its new position).
Front matter: scripts: ["/assets/js/jurisdiction-finder.js"], alongside (not instead of)
sortable-table.js — independent listeners on the same #fifty-table. No-JS fallback: the
<option value="massachusetts" selected> and the readout’s starting text (Massachusetts’s own two
numbers and ranks, written out as plain static text) are both already correct without JavaScript;
selecting a different option does nothing without JS, same as the pre-existing filter input next
to it — the table itself is already complete and correct either way.
5. The exhibits-gallery lightbox
Already fully built by lightbox.js — read that file’s own header comment for the complete
markup contract. Not data-driven; no assets/data/*.json file involved.
Read-back done on this pass
node assets/js/calculator.test.js— 38/38 checks pass (see file for what each one proves).- Every
assets/data/*.jsonfile spot-checked against its source CSV/JSON’s first and last rows and against figures already quoted incontext/MEMORY.md(Massachusetts $4,388.48/$4,714.22; the worked example’s $1,012.7257303613252 unrounded 7d; the 87.7%-income-share row’s 6.9% credit). - No em dash in any reader-visible string this pass added (
calculator.js’s flag text); the pre-existing em dashes in every file’s own code comments are not reader-visible prose and were left as the foundation pass wrote them. - Every script here is self-contained ES5 with no absolute local file path.