Check the Worksheet

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">&nbsp;</p>
    </div>
  </div>
  <p class="tool-flag" data-calc-flag>&nbsp;</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>&nbsp;</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:

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.


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