Check the Worksheet Sign the petition

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)
  contact.js                 -- contact form submit handling (contact/index.md only)
  petition.js                 -- petition dialog, corner card and the standalone /petition/ page's
                                inline form; loaded on every page, but only when
                                site.petition_endpoint is set (see _config.yml and CONVENTIONS.md)
  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

1280 checks: all six worked examples 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-09.txt to the cent; the sanity guard firing/clearing; (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; and (v3, 2026-09-07) the full children x custody x child-care-scenario x six-income-pair fixture grid, fixtures/calculator-childcare.json (108 rows, 0 disabled), generated by tools/gen_calculator_childcare_fixtures.py from this repo’s own model/ — unlike the v2 generator (see below), this one IS checked in, since the childcare control is this repo’s own addition rather than a port of a number already published elsewhere. All 1280 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/childcare/income combination the tool should support, regenerate the relevant fixture file from the Python first — never hand-write an expected value.

For fixtures/calculator-v2.json: 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.

For fixtures/calculator-childcare.json: run python3 tools/gen_calculator_childcare_fixtures.py — it imports model/worksheet.py and model/net_position.py from this repo directly (no path to the private repo needed) and writes the JSON in one step. See that script’s own header comment for the child-care scenario definitions (0 none, 1 recipient pays, 2 both pay) and the composition into net_position.analyze()’s weekly_childcare / payor_childcare_share arguments — the same analyze() call v2 already made, not a new function.

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>
    <fieldset class="segmented segmented--compact">
      <legend>Child care ($300/wk)</legend>
      <label class="segmented-option"><input type="radio" name="calc-childcare" value="0" data-calc-radio="childcare" checked> None</label>
      <label class="segmented-option"><input type="radio" name="calc-childcare" value="1" data-calc-radio="childcare"> Recipient pays</label>
      <label class="segmented-option"><input type="radio" name="calc-childcare" value="2" data-calc-radio="childcare"> Both pay</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">36.8%</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">$90,447/yr</p>
      </div>
      <div>
        <p class="cell-label">Recipient household holds</p>
        <p class="cell-value" data-calc-cell="recip_after">$90,631/yr</p>
        <p class="cell-sub">Per person: <strong data-calc-cell="recip_per_person">$22,658/yr</strong></p>
        <p class="cell-note" data-calc-note="recip_after">&nbsp;</p>
      </div>
    </div>
    <p class="tool-flag" data-calc-flag-household>&nbsp;</p>
  </details>
  <p>Three children, Box 1 (shared parenting), and no child care by default. 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. v3 adds one more: .segmented--compact, a modifier on the third, child-care fieldset that shrinks its horizontal padding and label font size so three segmented fieldsets fit .tool-controls-row without the tool growing noticeably taller — it does not shrink .segmented-option’s min-height, which stays the 44px WCAG 2.5.5 touch target every other segmented option already uses.)

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, all three 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%, 36.8%, $90,447/yr, $90,631/yr, $22,658/yr, $201,000/yr, $29,640/yr, kids=3, box=1, childcare=0, credits on) — 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): 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 fourth control for how many of the children are under 13). The tool now counts the refundable credits by default (changed 2026-09-09 evening, as the better default for the tool; net_position.py’s analyze() still defaults to credits-off, and every published figure in the comments, on the finding pages and in the charts stays on that withholding basis). So the kids-under-13 convention DOES bite the tool’s default readout: the Massachusetts Child and Family Tax Credit is held at zero qualifying children, which understates the recipient household by $440 per child under 13 against the site’s own worked example. That runs against this project’s own argument, which is the safe direction, and it is why the tool’s after-tax row can sit slightly below the worked-example figures. Switch the credits toggle to “Leave them out” and the convention has nothing left to bite: no refundable credit is counted, kids-under-13 changes nothing, and the after-tax row matches the site’s worked example exactly ($87,172/$77,395). Children (1/2/3, default 3), custody (Box 1/Box 2, default Box 1), and (v3, 2026-09-07) child care (None/Recipient pays/Both pay, default None) are all real controls — real <input type="radio"> elements in a <fieldset>/<legend>, keyboard-operable, each group’s name distinct. Child care is a fixed $300/wk claim per scenario rather than a fourth slider (see calculator.js’s v3 header comment for why a discrete scenario matches the site’s own findings better than a continuous range); “Recipient pays” assigns the whole $300/wk to Parent A (the lower earner), “Both pay” gives $300/wk to each parent. The two income sliders remain independent controls; if a reader drags “lower” past “higher” the script silently swaps which value plays which role so each label still names the parent it describes — 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-09.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.


6. The petition sign-up (2026-09-26)

Header trigger, corner card, <dialog>, and the standalone /petition/ page’s own inline form — petition.js’s own header comment has the full markup contract for all four. Gated end to end on site.petition_endpoint (_config.yml): empty means the script is never even requested, per the conditional script tag in _layouts/default.html. See CONVENTIONS.md’s own note on this feature and docs/plans/2026-09-26-site-petition-build.md in the private repo for the spec and the server’s request/response contract.

Uses a real <dialog> shown with showModal(), unlike lightbox.js’s hand-rolled <div role="dialog"> — while a native dialog is open with showModal(), the browser keeps focus inside it and treats the rest of the page as inert, so this needs no manual Tab trap the way lightbox.js needs one for its own dialog. Not data-driven; no assets/data/*.json file involved, and the form posts to a small purpose-built server rather than a third-party form backend (unlike contact.js, which does use one).


Read-back done on this pass

Read-back done on the v3 pass (child care control, 2026-09-07)

Sign the petition to fix child support →