Skip to content

Build the Tools menu from apps.json: the main app, then current-law calculators - #1424

Merged
MaxGhenis merged 13 commits into
mainfrom
feat/tools-menu-registry
Oct 26, 2026
Merged

MaxGhenis merged 13 commits into
mainfrom
feat/tools-menu-registry

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

This branch includes #1425, merged 2026-10-26. Vercel's website builds restored webpack's cache from an earlier deployment, and with it the CSS could miss new Tailwind classes; #1425 turned that cache off and has the details. This PR adds seven such classes: aspect-[16/9], lg:grid-cols-4, object-top, transition-[box-shadow,translate], and three focus-visible:outline-* classes. The preview at 026c73d lacked five of them. The preview at e3b09a3 has all seven (Verification).

Why

#1421 added a hand-picked Tools menu: CliffWatch, the OBBBA explorer and the state legislative tracker. Max asked why those three, out of the many interactives PolicyEngine has. He then ruled where the line falls:

  • Tool: works for any household under current law, at any time.
  • Research: about one policy, bill, event or finding, whatever its format.
  • The main app comes first in the menu.
  • The OBBBA explorer and the tracker are research, but stay on the homepage.

Change

  • The rule lives in apps.json. Each tool gets a toolsMenu: { "label": "…" } field; the label is the short name the menus show. Both headers and the homepage read that flag. featuredTools.json and its symlink are gone.
  • Tools menu, in both headers: "PolicyEngine app" first (app.policyengine.org/<country>, the same link as the homepage's "Enter PolicyEngine" button), then the tools sorted by label.
    • US: CliffWatch, Coverage Compass, GiveCalc, Marriage calculator, QBI deduction calculator, SPM threshold calculator, TANF calculator.
    • UK: Marriage calculator, Student loan calculator, UK CliffWatch.
    • A country with no tools gets no menu, so the main app never stands alone in one (ca, ng and il in the calculator).
    • The "All interactive tools" row is gone. The menu now lists every tool by rule, and research?types=interactive mixes tools with dated analyses.
    • In the calculator's desktop header, a dropdown row whose link isn't a calculator path is now an <a>. It used to be a <button> whose click set window.location.href. So Cmd/Ctrl-click opened the link in the same tab, and the row had no "Open link in new tab". This covers Tools and Model, and About in calculator mode. Rows with calculator paths stay buttons that route client-side. The mobile menu already used anchors.
    • The header's test for "a calculator path" (isInternalHref) used to accept anything starting with /. It now requires exactly one leading slash, because //host and /\host name another host, and no tab or line break: the URL parser drops those, so /<tab>/host names another host too. No current link has any of these shapes.
  • Homepage:
    • The "Interactive tools" section becomes "Tools": one card per menu row, in menu order, on both the US and UK homepages.
    • A new US "Featured research" section holds the tracker and the OBBBA explorer. Their cards take title, copy and image from apps.json.
    • Card focus ring. The cards' focus style read var(--color-primary-500). Tailwind's tw prefix renames theme variables to --tw-*, and the compiled CSS defines no --color-primary-500, so the shadow drew nothing while outline-none hid the browser's ring. Keyboard focus on a tool card was invisible; the old US section had the same bug. The cards now get a 2px primary-500 outline with a 2px offset, and their transition covers only the hover lift (box-shadow, translate), so the outline appears at once instead of fading in from a 3px dark ring.
    • The blog cards carried the same rule, as focus-within on the div inside the card's link. Focus lands on the link, which wraps that div, so the rule never matched. Those cards have always shown the browser's default ring and still do. I deleted the dead rule.
    • alert.tsx had the same kind of bug. Its icon column was calc(var(--spacing)*4), but Tailwind's prefix emits the default theme's spacing as --tw-spacing, so that column was undefined. It now uses --spacing(4), which compiles to calc(var(--tw-spacing) * 4); I checked by compiling it with the repo's Tailwind 4.2.1. No page draws it yet: the only <Alert>, on the API status page, has no icon.
  • apps.json, besides the flags:
    • Entries for the SPM threshold calculator and the QBI deduction calculator. Both were live zones (/us/spm-calculator, /us/qbi-calculator) with no registry entry. They aren't listed with research (displayWithResearch unset, like GiveCalc's).
    • The tracker's description now says what the live tracker does: federal and state bills, with scored bills' budget, poverty and household impacts. The old text described a 2026 state session map. Its title, "2026 state legislative tracker", still says "state", but so does the tracker's own page ("2026 State Legislative Tracker"), so I left it. Renaming the tracker is for its owners.

Which interactives are tools

I opened each candidate on policyengine.org on 2026-10-25.

Entry Verdict What the live page shows
cliffwatch, uk-cliff-watch Tool A household's resources as earnings rise
coverage-compass Tool ZIP code, household, life events
givecalc Tool A gift and a household, for 2025–2027
marriage (US, UK) Tool A couple's taxes and benefits married vs. unmarried (US 2026–2028; UK 2026-27 on)
tanf-calculator Tool State, household, income
uk-student-loan-calculator Tool A borrower's details
spm-calculator Tool Location, household and year → the local SPM threshold
qbi-calculator Tool Filing status, tax year 2024–2026, income sources → §199A deduction
obbba-households, bill-tracker Research (homepage) One law; legislation as it moves
aca-calc Research "ACA Premium Tax Credits: 2025 vs 2026", the IRA enhancements expiring
snap-payment-error-simulator Research State QC error rates and cost share, not households
refundable-credit-conversion Research A reform explorer
state-eitcs-ctcs Research A poverty-impact dashboard of state credits
rhode-island-ctc-calculator Research "Design a Rhode Island child tax credit"
uk-salary-sacrifice-tool Research One budget cap
two-child-limit-comparison, local-areas-dashboard Research One reform; area-level results
child-tax-credit-calculator Left out Stale: still says $2,000 per child in 2024 and $1,000 from 2026 (pre-OBBBA)

Invariants (tested)

For every country C:

  1. C's menu is the main app (<calculator>/C), then exactly the apps.json entries for C that have a toolsMenu label, sorted by label. This is a property test over arbitrary country ids, __proto__ and constructor included (website/src/__tests__/lib/toolsMenu.test.ts). The US and UK lists above are pinned literally.
  2. A country with no tools has no menu.
  3. Every tool link is /C/<slug>, a path the website proxies to a zone (appZoneRoutes).
  4. Menu labels and ids are unique.
  5. Differential: the calculator header's lookup (app/src/utils/toolsMenu.ts) returns the same tools in the same order as the website's, for arbitrary country ids. Both name the main app the same way, and both read one apps.json (the website's copy is a symlink).
  6. Both headers render the menu in order, desktop and mobile, and every row is a link. Website rows are full page loads (next/link is mocked to tag client links). In the calculator, each desktop row is an <a> with the same absolute URL as its mobile row.
  7. The homepage's tools section shows one card per menu row, in order, with the entry's copy. The hero button links where the menu's main-app row does.
  8. Featured research is the tracker, then the OBBBA explorer: their apps.json entries, existing images, proxied zone paths. Neither is ever in the menu.
  9. A toolsMenu value is exactly { label }: trimmed and non-empty.
  10. A mocked apps.json test checks that the homepage really reads the registry. A new flagged tool gets a card in label order with its own copy, and a featured entry missing from the registry is skipped.
  11. Calculator dropdown rows (NavItem.test.tsx).
    • Property test. For any rows, nested ones included, a row is a link exactly when it has an href that isn't a calculator path. Then it links to that href. The generated hrefs cover other sites, //host, /\host, /<tab>/host, /<line feed>/host, /<carriage return>/host, #top, mailto:, relative paths and calculator paths, plus rows that only have a callback. A calculator path with a tab in it is left to the browser as a link.
    • Examples. Clicking a link row, plain or with Cmd, Ctrl or Shift, leaves the navigation to the browser. The row's click is the only one that reaches the document, and it arrives with its default intact, so nothing calls preventDefault or stops it. The URL hasn't changed right after the click. Once the timers (through the global timer functions), animation frames and microtasks the click queued have run, the router hasn't been called, jsdom has reported no navigation, window.open or form submission, and neither the URL nor the history length has changed. That rules out location.assign, replace or reload, setting location.href or its hash, window.open, history.pushState or replaceState (even when a timer later puts the URL back), submitting a form, clicking a second link, and any of these deferred to a timer, an animation frame or a microtask. A timer started through a reference to setTimeout taken before the test fakes timers is outside this. jsdom can't see history.back(), a reload by setting location.href to itself, or the Navigation API, so those aren't covered. The click also closes the dropdown. A calculator-path row is a button that routes client-side.
  12. Focus. Every homepage tool and research card meets all of these:
    • It has one focus-visible outline width, at least 2px.
    • It has one outline colour from the theme, with at least 3:1 contrast against the background of the section it sits in (WCAG 1.4.11). The outline is offset, so it's drawn on that section, not on the card.
    • It has no outline-none or outline-hidden. Both set outline-style: none, which focus-visible:outline-2 keeps.
    • Its transitions can't animate the outline. A transition-property utility leaves the outline out only when its value is none, or a comma-separated list of properties known to exclude it (box-shadow, translate, transform, scale, rotate, opacity), in any case. Any other value counts as including it: transition, transition-all and transition-colors; all, an outline… property or a variable; a CSS-wide keyword, since the initial value is all; and a value the browser rejects, such as box-shadow translate, since the browser then keeps all. Any transition shorthand counts too, and so does an arbitrary transition property with any / modifier. One of 100% (/100, /[1], /[100%]) leaves its value as written; a variable one, such as /(--x), emits the value and a color-mix() override; any other emits nothing or a color-mix() the browser drops (for transition-property, that keeps all). The rule counts them all, so here it can only over-count. An arbitrary custom property, such as [--card\.gap:1], sets no transition. The important modifier (!transition-all, transition-all!) and the -webkit- aliases, in any case, count as the plain utility and properties do.
    • Since CSS's initial transition-property is all, a duration or a delay needs a transition-property utility without a variant that leaves the outline out. A delay alone starts a transition: CSS Transitions starts one when max(duration, 0s) + delay is above zero, so the outline would appear late. Time units are case-insensitive, so delay-[100MS] counts.
    • Differential: for 53 single classes, the rule's reading of each utility agrees with what Tailwind compiles it to. This checks Tailwind's half: which property names include the outline, and which timings start a transition, are the same CSS rules on both sides. Within that, it never misses a class the compiled CSS animates. It's exact except on [transition:box-shadow_1s], because the rule counts every shorthand as animating, which is intended. Tailwind emits an arbitrary property only when its name starts with - or a lower-case letter, and keeps the name as written; the browser reads it case-insensitively. So [TRANSITION-DELAY:100ms] compiles to nothing, and [transition-Delay:100ms] to a delay, and the test checks both. That is the compiler handed each class directly, as @source inline does. On the site, Tailwind's scanner finds the classes in source files, and it extracts none of the test's mixed-case or escaped transition names (I checked all 11), so there they compile to nothing and the rule's reading only over-counts. A name with a CSS escape, such as [transition\-delay:100ms], counts as a transition shorthand. The oracle doesn't decode escapes, so that one is checked on a card, not in the differential.
  13. Theme variables (theme-variables.test.ts). No website source reads a theme variable that exists only with the tw prefix.
    • The test collects variables from every @theme block, whatever its modifiers, in globals.css and every stylesheet it imports. That includes Tailwind's default theme, which is why --spacing counts.
    • It scans globals.css whole, @theme values included. Tailwind doesn't prefix a read inside an @theme value, so an alias such as --color-ring: var(--color-primary-500) reads an undefined variable too.
    • It matches var(--x), Tailwind's -(--x) shorthand, and the shorthand with a type hint, -(color:--x), even when a read spans lines.
    • It ignores comments. For .ts and .tsx files, TypeScript's parser finds them, so a // or /* in a string, a template literal, a regular expression, JSX text or a JSDoc tag isn't one. That includes the comments after a file's last statement when the file ends with a JSDoc, which TypeScript files under the end-of-file token. In CSS, a comment is a /* … */ outside a string.
    • A variable a stylesheet defines outside @theme is allowed, such as ui-kit's --font-sans.
    • The scan itself, exhaustively: for every one of the prefix-only variables (498 today) in each of 24 read shapes (four syntaxes, three spacings, with and without a fallback), the read is flagged and its prefixed form isn't.
    • Comments, by property test (300 runs): a sampled read in each of 15 code contexts is flagged at the line it starts on, and in each of 11 comment contexts it isn't. Blanking comments keeps every line break. --spacing(4) is not flagged.
  14. Tests add nothing to the site's CSS (tailwind-test-sources.test.ts). Tailwind scans all of website/, tests included. A compile of globals.css with src/__tests__ and one without emit the same declarations, each with the rules and at-rules around it. So a test that names only a theme variable, which adds a :root declaration and no rule, fails it too. While I wrote this, its own doc comment named a theme variable, and the check caught it. At 026c73d the focus test wrote class names out and added three classes to the CSS: tw:transition-[box-shadow,all], tw:transition-[outline-color] and tw:motion-safe:transition-all. Tests now build class names at run time.

The lookup is written twice because the calculator can't import website code. The differential test imports the calculator's module into the website suite to compare the two directly.

Mutant checks, each run against the suites in a cloned tree. Round 1 (mutants4.py): all 37 fail at least one test. I re-ran the 17 that touch code changed in round 2 at 026c73d, and all 17 still fail.

  • Website lookup: tools unsorted, the main app shown with no tools, the country filter dropped, the main app last.
  • Website rendering: header rows as client links, homepage tool or research cards as next/link, hardcoded card copy, featured research reversed, its copy taken from the title, a wrong image path, US research featured on every country, and the hero button linking elsewhere than the menu's first row. The last survived at first because no UK entry shares a featured US slug. A mocked UK twin now kills it.
  • Data: the OBBBA explorer flagged as a tool, GiveCalc unflagged, an extra key in toolsMenu, a 50-character label (caught by the pinned US list and the dropdown width test), and a changed label (caught by the pinned US list).
  • Calculator: tools unsorted (caught by the app's tests and by the website's differential test), the country filter dropped, the main-app row dropped or linked to the website, Tools shown for every country, a different main-app label.
  • Calculator dropdown rows: every row a button again, in-app rows made anchors too, an anchor row that leaves the dropdown open, one that links elsewhere, an in-app row doing a full page load, nested rows losing their anchors.
  • Focus: the cards' ring reading the unprefixed variable again, the outline removed, the transition back to transition-all or animating the outline color, the blog cards' dead rule restored, and globals.css reading var(--color-border).

Round 2 (mutants5.py): 19 of 22 fail at least one test. The other 3 are changes the tests should allow, and they pass.

  • Theme-variable guard, against the code: alert.tsx reading --spacing unprefixed again; a bg-(--color-primary-50) shorthand; a default-theme colour read through var(); a var( split across lines in globals.css; a variable from an @theme inline block read unprefixed.
  • Theme-variable guard, against the guard: it stops following @import, strips every // (URLs included), misses the -( shorthand, or misses reads that span lines.
  • Focus: the cards' transition set to transition or transition-colors; a primary-300 outline (under 3:1); a 1px outline; the check treating transition-colors as safe.
  • Calculator rows: isInternalHref back to startsWith('/'), or missing /\host; an anchor row that also routes client-side, prevents the browser default, or leaves the dropdown open.
  • Allowed, and passing: a comment that names an unprefixed variable; a darker primary-600 outline; a label longer than its app's title.

Round 3 (mutants7.py): all 31 fail at least one test.

  • Theme-variable guard, against the code (the round-3 review's G1–G4): a type-hinted bg-(color:--color-primary-50); an @theme alias --color-ring: var(--color-primary-500); a read after bg-[url(//…)] on the same line; a read below a // … /us/*. comment. Plus the plain bg-(--color-primary-50) control.
  • Theme-variable guard, against the guard: trailing comment ranges dropped; JSX text or JSDoc nodes read for comments; CSS comments found inside strings; the type hint dropped from the pattern; the pattern covering only the first 200 names; the round-3 regex comment heuristic.
  • Focus, against the card (H0–H3 and more): outline-none; the transition list dropped with duration-300 kept; outline-hidden; focus-visible:outline-transparent; [transition-property:outline-color]; hover:transition-all.
  • Focus, against the rule: durations never animating; a variant's transition list counting for the base state; shorthands ignored; any arbitrary transition-property safe; transition-colors listed as safe; duration-initial counted as a duration.
  • Test sources: a test file writing out a class name.
  • Calculator rows (N4, N6–N8 and more): an anchor row that prevents the default, stops propagation, does both, or also calls window.location.assign; isInternalHref accepting tabs and line breaks, or only carriage returns.

Round 4 (mutants9.py): all 19 mutants fail at least one test, the 2 allowed changes pass, and 3 runs with part of a fix removed show which assertion does the killing.

  • The round-4 review's mutants: a real read before a file-final JSDoc; a card transition of transition-[initial], [transition-property:unset], transition-[inherit] or transition-[revert-layer] with a duration; a card with a delay alone, as delay-100 or [transition-delay:50ms]; a test file naming a theme variable, a class or an animation; an anchor row that also calls window.open(href, '_self'), location.assign, sets location.href or calls history.pushState.
  • Each fix undone: the end-of-file token read as a parent again; CSS-wide keywords not counted; delays not counted by the rule, or not read by its oracle; delay-0 counted as a delay.
  • Which assertion kills them: with selectors compared again, the theme-variable test survives; with the old message filter, window.open survives; without the URL check, history.pushState survives.
  • Allowed, and passing: a comment naming a read before a file-final JSDoc (the round-4 false positive); a safe transition list with a delay.
  • Round 3's 31 mutants (mutants7c.py, its anchors updated for the renamed setsTiming) all still fail.

Round 5 (mutants-r6.py, the round-5 review's mutants against the fixed tests): 21 of its 26 mutants fail at least one test. Of the other five, two change nothing and three are beyond jsdom. S1 and S3 name a class and a variable the site's own code already uses, so the CSS is the same. N20, N25 and N26 navigate through the Navigation API, by setting location.href to itself, and by history.back().

  • The round-5 review's mutants:
    • card transitions of transition-[box-shadow_translate], transition-[box-shadow,none], !transition-all, transition-all!, !delay-100, transition-[ALL], [-webkit-transition:all_300ms] and [-webkit-transition-delay:100ms];
    • a test naming a media-query class, an animation or an important class;
    • an anchor row that also sets the hash, calls replaceState, reload, window.open(href) or location.replace, defers location.assign or a router push to a timer or a microtask, clicks a second link, or submits a form.
  • Each part of the fix undone, alone: all 10 fail a test. They are: no important-modifier strip; no -webkit- alias in the rule, in its timing or in the oracle; list names compared case-sensitively; none not special-cased; the rule's or the oracle's zero test case-sensitive; arbitrary property names lower-cased; and the round-5 denylist back.
  • Which assertion kills them: without the timer flush, the deferred location.assign (timer and microtask) and the deferred router push survive. Without the click-target check, the second link survives. With the old message filter, the form submission survives.
  • Rounds 3 and 4 again (mutants7c-r6.py, mutants9-r6.py, anchors updated for the rewritten rule): all 31 and all 19 still fail, the 2 allowed changes pass, and the 3 runs with part of a fix removed survive, as before.

Round 6 (mutants-r7b.py, the round-6 review's mutants against the fixed tests): all 17 of its mutants fail at least one test, and the 3 allowed changes pass.

  • The round-6 review's mutants:
    • card transitions of [transition-Delay:100ms], [transition-Duration:300ms], [transitioN:all_300ms], [-webkit-Transition:all_300ms], [-WEBKIT-transition:all_300ms], [transition-Property:all] after the safe list, [-webkit-transition-Delay:100ms] and [transition\-delay:100ms];
    • an anchor row that calls pushState and has a timer put the URL back; this one also fails the 5795ac0 test;
    • a router push deferred to an animation frame, a 60-second timer, an interval, a promise–timer–promise chain, a MessageChannel or postMessage; a click on a detached second link, or on an attached one that stops propagation.
  • Allowed, and passing: the differential sampling [transition-Delay:100ms] or [transition-Property:all], and a menu that closes a tick late (which the 5795ac0 test failed).
  • Each part of the fix undone, alone (mutants-r7a.py): all 5 fail a test. They are: no first-letter gate; arbitrary names not lower-cased; no escape guard; arbitrary names keeping a -webkit- prefix; and timing ignoring arbitrary properties.
  • Which assertion kills them: without the history-length check, the pushState the timer undoes survives.
  • Rounds 3 to 5 again (mutants7c-r7.py, mutants9-r7.py, mutants-r7a.py): round 5's 21 still fail, its 2 no-op changes pass, and the 3 beyond jsdom survive. The 7 checks of its fix parts that still apply fail, and its 5 runs with part of a fix removed survive, as before. Round 4's 19 and round 3's 31 still fail, round 4's 2 allowed changes pass, and 2 of its 3 runs with part of a fix removed survive. Round 4's history.pushState row without the URL check is now killed by the history-length check; it survives only with both checks removed.

Round 7 (mutants-r8-review.py, the round-7 review's mutants against the fixed tests): all 11 of its mutants fail at least one test, and the 5 allowed changes pass.

  • The round-7 review's mutants:
    • card transitions with a / modifier: [transition:all_1s]/100, [transition-delay:100ms]/100, [transition-duration:300ms]/100, [-webkit-transition:all_1s]/100 and [transition-delay:100ms]/[1], and, after the safe list, [transition-property:all]/100, [transition-property:all]/100! and [transition-property:all]/[100%];
    • an anchor row that calls replaceState or location.replace('#x') and has a timer put the URL back, or calls pushState and has a timer call history.back(). The first two also fail the 5795ac0 test, which checked the URL before running timers.
  • Allowed, and passing: the differential sampling [transition:all_1s]/100, [transition-property:all]/100, [transition-property:box-shadow]/50 or [transition-delay:100ms]/[1], and a card with [color:red]/50.
  • Each part of the fix undone, alone: all 4 fail a test. They are: the round-7 pattern, with no modifier; a modifier counting on any property; a modified name read as written; and no custom-property exemption. The round-6 escape guard, rewritten, still fails a test when removed.
  • Which assertion kills them: without the after-click URL check, the replaceState the timer undoes survives.
  • One case is left unscored, as the review left it: a router push through a setTimeout captured before the test fakes timers. Its real timer fires during a later test, so which tests fail varies by run (2 of the 4 link-row tests here, 3 in round 7). Invariant 11 doesn't claim it.
  • Rounds 3 to 6 again (mutants-r8b.py, mutants-r8a.py, mutants9-r8.py, mutants7c-r8.py, anchors updated): every result is as before. Round 6's 17 fail and its 3 allowed changes pass; its pushState row survives only with both the history-length and the after-click URL checks removed. Round 5's 21 and the 12 checks of rounds 5 and 6's fix parts fail, its 2 no-op changes pass, and the 3 beyond jsdom and its 5 runs with part of a fix removed survive. Round 4's 19 and round 3's 31 fail, round 4's 2 allowed changes pass, and 2 of its 3 runs with part of a fix removed survive; the third survives only with the URL, after-click URL and history-length checks all removed.

Round 8 (mutants-r9.py: the round-7 set plus the round-8 review's cases, against the fixed tests). The clone predates a comment-only edit in 4e6742b.

  • The round-8 review's cases: cards that add [transition-property:all]/(--x,var(--y)), /(--x) or /[var(--x)] after the safe list. All 3 fail a test.
  • The fix undone: the round-7 pattern restored, with or without its paren branch. Both fail a test.
  • The round-7 set again: every result is as in round 7: 39 fail, 8 allowed changes and the 2 baselines pass, and the replaceState run without the after-click check survives. The timer captured before the test fakes timers is still unscored (it failed in this run).
  • Rounds 3 to 6 again (mutants-r9b.py, mutants-r9a.py, mutants9-r9.py, mutants7c-r9.py): every result is as in round 7.

Verification

  • website at 4e6742b: typecheck, full suite 53 files / 477 tests; ESLint and Prettier on the changed test
  • app at 54478b5 (no app file changed since): typecheck, NavItem.test.tsx (19 tests), ESLint and Prettier on the changed file; the full suite runs in CI. At 5795ac0: full suite 456 files / 5,881 tests (10 skipped). At fe60492: bun run check and Stylelint
  • calculator-app at fe60492: lint, typecheck (later rounds change no calculator-app file; Build the website without webpack's cache so new Tailwind classes ship #1425's change to it came in with main)
  • Local dev server, 1280×800:
    • The Tools dropdown lists the 8 US rows in order, each a single line (40 px).
    • Featured research: two cards side by side.
    • Tools: a 4×2 grid of 295 px cards, document width 1280.
    • /uk: four tool cards, no featured research, and no link to cliffwatch, obbba or bill-tracker.
  • Local dev server, keyboard focus (1024×768): tabbing onto a tool card or a featured research card gives solid 2px rgb(49, 151, 149), offset 2px, from the first frame (no running animations). A blog card keeps the browser's default auto ring.
  • Local dev server, 375×667:
    • Both sections stack (cards 335 px wide, document width 375).
    • The mobile menu scrolls: 863 px of content in a 667 px panel, with Donate's bottom at 651 px.
  • Vercel preview (website, at fe60492, before the review fixes), 1280×800:
    • The US Tools dropdown lists the 8 rows in order, each 40 px.
    • Clicking "SPM threshold calculator" is a full page load onto the SPM calculator zone. A marker on the old document was gone and the navigation entry was /us/spm-calculator.
    • The homepage shows "Featured research" and "Tools".
  • Vercel preview (calculator, at fe60492) /uk/reports: the legacy header's Tools dropdown lists "PolicyEngine app", "Marriage calculator", "Student loan calculator" and "UK CliffWatch", each 40 px.
  • Vercel preview (website, at e3b09a3), 1280×800. Its build log says the built CSS "has all 598 classes of a fresh compile" (Build the website without webpack's cache so new Tailwind classes ship #1425's check), and the page's stylesheets have rules for all seven new classes.
    • Tools: a 4×2 grid of 295 px cards, document width 1280; each card's transition is box-shadow, translate over 0.3 s.
    • Featured research: both images loaded, in 16:9 boxes with object-position: 50% 0%.
    • Hover: a tool card moves to translate: 0px -2px and gets the larger shadow. A reading taken during the move gave -0.52px, so the lift animates.
    • Keyboard focus: tabbing onto the first tool card gives solid 2px rgb(49, 151, 149), offset 2px, with no running animations.
  • Independent review.
    • Round 1 (GPT-6.1 Sol, at fe60492) asked for three changes: the invisible card focus ring, calculator rows that weren't anchors, and the fixed label cap. All three are made in 9dc0e30.
    • Round 2 (Opus 5.5, at 9dc0e30) asked for eight: a broader theme-variable guard, the merge order, the transition and contrast checks, //host rows, a test that anchor rows never route, the Cmd/Ctrl wording, the label rule and the tracker's copy. All eight are addressed in 026c73d; the tracker's title stays, for the reason under Change.
    • Round 3 (Opus 5.5, at 026c73d) asked for seven: reads in globals.css's @theme and type-hinted reads, comment stripping that blanked real code, the scan sampling its names, a stale flag in the modifier-click test, two ways to break the outline, tab and line-break hrefs, and the preview item's wording. All seven are addressed in 5b2270b.
    • Round 4 (Opus 5.5, at 5b2270b) approved, with four nits: a file-final JSDoc hid the comments before it from the guard; the focus rule missed CSS-wide keywords and delays; the test-sources check compared selectors only; and the navigation check missed window.open. All four are fixed in the tests in 5795ac0, and the differential's claim was narrowed to Tailwind's half.
    • Round 5 (Opus 5.5, at 5795ac0) approved, with two nits. The focus rule's denylist still passed seven card transitions that Chrome animates: a space-separated list, none inside a list, the important modifier, upper-case keywords and the -webkit- aliases. And the navigation check missed a click deferred to a timer or a microtask, a second link clicked, and a form submission. Both are fixed in 4ccc029: the rule is now an allowlist, and the NavItem test runs the click's timers and checks every click that reaches the document. Invariants 11 and 12 now say what they cover.
    • Round 6 (Opus 5.5, at 4ccc029) asked for two changes. The focus rule read arbitrary property names as written. The differential hands each class to Tailwind's compiler, which emits a name with an upper-case letter after the first as written, and the browser reads it case-insensitively; so the rule disagreed with its own oracle, and the differential enforced that. And the timer flush let a timer undo a pushState's URL change. Both are fixed in 144284c: the rule reads arbitrary names case-insensitively, as the compiler emits them and the browser parses them, and the NavItem test checks the history length.
    • Round 7 (Opus 5.5, at e3b09a3) asked for three changes. First, a correction to round 6: the site's classes come from Tailwind's scanner, which extracts none of those mixed-case or escaped names, so on the site they compile to nothing and round 6's eight cards don't animate. For the 10 mixed-case names the test uses, my earlier statement holds on the site, and the scanner doesn't extract the escaped one either; the statement was wrong for the compiler handed a class directly. This body and the test comments now say which is which; the rule stays case-insensitive, which on the site only over-counts. Second, an arbitrary transition property with a /100 modifier, which the scanner does extract (as it does every modified class the test uses) and Tailwind ships with its value as written, read as no transition; and an escaped custom property read as a shorthand. Third, a replaceState that a timer undoes still passed the anchor-row test. All three are fixed in 54478b5, the code fixes being the review's patches.
    • Round 8 (Opus 5.5, at 54478b5) approved, with three nits. The rule's modifier match stopped at the first ), so a variable modifier with a fallback, [transition-property:all]/(--x,var(--y)), read as no transition while Chrome 152 animates the outline; invariant 12 misdescribed /[1] and /[100%]; and the round-7 entry called the escaped name mixed-case. All three are fixed in 4e6742b, the code fix being the review's patch.
    • Round 9 (Opus 5.5, at 4e6742b) approved, with two text nits. The round-8 mutants paragraph counted the unscored timer case and the baselines (39 fail and 8 allowed, not 40 and 10), fixed here. And the rule's JSDoc says the compiler keeps a value "when the modifier is 100%" and otherwise mixes it or emits nothing, which leaves out the variable modifier (described at its card) and /[calc(100%)], which mixes. That comment is left as is: the rule counts every /… modifier, so neither case changes what the test checks.

Notes

  • Bundle: both headers now import apps.json: 35.1 kB raw, 6.8 kB gzipped. The old list was 1 kB. On the website, the homepage and research pages already import apps.json in client components (HomeBlogPreview, ResearchClient); other pages now load it too. The calculator gains it as well. I didn't measure built chunk sizes.
  • The research listing is unchanged. Tools that were listed with research still are; the two new entries aren't. Whether tools should leave the research listing is a separate call.

Not in this PR

axiom: n/a: website navigation, homepage and registry data only

🤖 Generated with Claude Code

…alculators

A tool now means a calculator that works for any household under current
law. apps.json marks each one with a toolsMenu label, and both headers and
the homepage read that flag instead of featuredTools.json.

- Tools menu: PolicyEngine app first, then the flagged tools by label. US:
  CliffWatch, Coverage Compass, GiveCalc, Marriage calculator, QBI deduction
  calculator, SPM threshold calculator, TANF calculator. UK: Marriage
  calculator, Student loan calculator, UK CliffWatch. "All interactive
  tools" goes: the menu is complete by rule.
- The OBBBA explorer and the bill tracker leave the menu and move to a
  "Featured research" section on the US homepage.
- The homepage "Tools" section shows the menu's entries, on the UK homepage
  too.
- apps.json gains entries for the SPM and QBI calculators, which were zones
  with no registry entry, and the tracker's description now matches what the
  live tracker shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-calculator-next Ready Ready Preview Oct 26, 2026 11:58am UTC
policyengine-website Ready Ready Preview Oct 26, 2026 11:58am UTC

Request Review

…mepage sections

- toolsMenu: the US and UK menus Max ruled on; for any country id, the main
  app then exactly the flagged apps by label, and no menu without tools;
  links stay in-country and hit proxied zones; a differential test that the
  calculator header's lookup returns the same tools in the same order.
- featuredResearch: the tracker then the OBBBA explorer, with their
  apps.json copy and existing images, never also in the Tools menu.
- Both headers render the menu in order, desktop and mobile, as full page
  loads; ca/ng/il get none.
- Homepage: a card per menu row in order; featured research cards; a
  mocked-apps.json test that new flagged tools appear in label order with
  their own copy; the hero button and the menu's main-app row agree.
- apps.json: toolsMenu is exactly a short, trimmed label.
- The OBBBA and CliffWatch research-search checks move to the search
  corpus tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he top

The homepage tool cards show each entry's apps.json description, so the
four that began "Interactive calculator showing" or "Interactive tool to"
now start with the verb, and all end with a period. The claims are
unchanged. Featured research images anchor to the top, so the tracker's
screenshot keeps its title bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for #1424:

- The homepage tool and research cards' focus ring read
  var(--color-primary-500), which the tw prefix renames to
  --tw-color-primary-500, so keyboard focus drew nothing. They now get a
  2px primary-500 outline, and only the hover lift animates, so the
  outline appears at once. The blog cards' copy of the rule never
  matched (focus-within on a div inside the focused link) and is removed.
- A guard test fails on any unprefixed read of a prefix-only theme
  variable, and the cards' focus classes are tested.
- In the calculator's desktop header, dropdown rows with absolute links
  are now anchors, so modifier-clicks and "Open link in new tab" work.
  In-app rows stay buttons that route client-side.
- A toolsMenu label is now bounded by its entry's title instead of a
  fixed 28 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly in-app paths as router links

- theme-variables.test.ts follows globals.css's imports, Tailwind's default
  theme included, and flags var(--x) and the -(--x) shorthand for any
  prefix-only variable, across lines, outside comments. A property test
  checks the scan itself.
- alert.tsx read --spacing unprefixed; it now uses --spacing(4).
- The card focus test requires a 2px outline with 3:1 contrast against the
  section behind it, and no transition that animates the outline.
- NavItem treats "//host" and "/\host" as other sites, so those rows are real
  anchors; tests check that an anchor row never calls the router and that
  modifier-clicks reach the browser.
- The toolsMenu label no longer has to be shorter than the app's title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d keep test classes out of the CSS

- The theme-variable guard scans globals.css whole, matches type-hinted
  shorthand reads, takes .ts/.tsx comments from TypeScript's parser, and
  checks every prefix-only name in every read shape.
- The focus test rejects outline-hidden, catches a duration with no
  transition-property, reads arbitrary properties, and checks its rule
  against Tailwind's compiled CSS.
- isInternalHref rejects tabs and line breaks, which the URL parser drops.
- The modifier-click test adds its listener after the menu opens, and checks
  that nothing navigates another way.
- Tests build class names at run time, and a new test checks that they add
  no rules to the site's CSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The comment finder reads the end-of-file token's trivia, so a file-final JSDoc no longer hides the comments before it.
- The focus rule and its compiled-CSS oracle count CSS-wide keywords as all properties, and a nonzero delay as starting a transition.
- The test-sources check compares every declaration in its context, so a test that names only a theme variable fails it.
- The modifier-click test counts window.open as navigation and checks the URL is unchanged, which catches history.pushState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The focus rule now treats a transition-property value as safe only when
it is none or a comma-separated list of properties known to leave the
outline out. Any other value, including one the browser rejects and so
replaces with `all`, counts as including it. The rule strips the
important modifier, reads -webkit- aliases, and compares values without
regard to case; arbitrary property names stay as written, because
Tailwind emits nothing for upper-case ones, which the differential
checks.

The dropdown test now runs whatever the click deferred to a timer or a
microtask, checks that the row's click is the only one reaching the
document, and counts form submissions as navigations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ength

Tailwind emits an arbitrary property only when its name starts with `-`
or a lower-case letter, and keeps the rest of the name as written; the
browser reads the name case-insensitively. The focus rule now applies
that gate, lower-cases the name, reads -webkit- aliases in any case, and
counts a name with a CSS escape as a shorthand. The differential gains
four mixed-case classes.

The dropdown test now also requires that the click added no
session-history entry, which catches a URL change a timer later undoes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Focus rule: an arbitrary transition property with a `/` modifier counts
  as animating (`/100` keeps the value; other modifiers make a colour the
  browser drops, keeping `all`), and a custom property never does. Add
  the cases, and two classes to the differential (53).
- Comments: say that the differential hands each class to Tailwind's
  compiler directly, and that the site's scanner extracts none of the
  mixed-case or escaped names, so there the rule only over-counts.
- NavItem: check the URL right after the click too, so a replaceState or
  location.replace that a timer undoes fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The focus rule's modifier match stopped at the first `)`, so a variable
modifier with a fallback, such as `[transition-property:all]/(--x,var(--y))`,
read as no transition while Chrome animates the outline. Any `/…` now
counts (the round-8 review's patch), with two variable-modifier cards.
The comments say which modifiers keep the value, emit nothing or mix it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 26, 2026 12:12
@MaxGhenis
MaxGhenis merged commit 3c53633 into main Oct 26, 2026
10 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merged with --admin at the reviewed head, 4e6742b151e581fc9308708630f7163ed692f7db. The only block was the required approving review: this is Max's own PR, so he can't approve it himself.

  • Scope. Max set it: the Tools menu lists the main app first, then every tool that works for any household under current law, from toolsMenu in apps.json. The OBBBA explorer and the tracker move to research and stay on the homepage.

  • Independent review. Nine rounds:

    • Round 1: GPT-6.1 Sol, at fe60492.
    • Rounds 2 to 9: Opus 5.5.
    • Round 9, at 4e6742b, approved. Its two text nits are handled in the PR body.

    Every finding from rounds 1 to 8 was fixed. The record is under Verification in the body.

  • Checks.

    • gh pr checks exited 0 at 4e6742b: 10 of 10 passed (PR checks run 40318879877, Chromatic run 40318880083).
    • The PR was MERGEABLE, not a draft, and had no review requesting changes.
    • Locally: the website's full suite (53 files, 477 tests), the app's NavItem tests, typecheck, ESLint and Prettier.
  • Review records are under ~/reviews/app-v2-pr1424/ on Max's machine: review-r1.md to review-r9.md, plus the mutant logs.

@MaxGhenis
MaxGhenis deleted the feat/tools-menu-registry branch October 26, 2026 12:22
MaxGhenis added a commit that referenced this pull request Oct 26, 2026
#1424 replaced featuredTools.json with apps.json's toolsMenu entries
and getToolsMenuEntries. The More and phone menus now list that
accessor's tools after Research, in its order. The headers' main-app
row is left out because this bar is the main app, and the "All
interactive tools" row goes, as it did in the headers.

The tests follow the accessor. A mocked accessor keeps the order and
no-tools cases covered whatever apps.json marks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit to PolicyEngine/policyengine-ui-kit that referenced this pull request Oct 26, 2026
PolicyEngine/policyengine-app-v2#1424 replaced the hand-picked list with
a rule: the PolicyEngine app first, then every calculator that works for
any household under current law, sorted by label, from the `toolsMenu`
labels in apps.json. Follow it.

- PolicyEngineTools.ts holds each country's tools (slug and label), the
  main app's label and its default URL. US: CliffWatch, Coverage Compass,
  GiveCalc, Marriage calculator, QBI deduction calculator, SPM threshold
  calculator, TANF calculator. UK: Marriage calculator, Student loan
  calculator, UK CliffWatch.
- The Tools menu lists the main app at <appUrl>/<country>, then the
  tools. "All interactive tools" is gone, as on policyengine.org.
- getPolicyEngineNavItems takes an optional app URL; PolicyEngineHeader
  takes an appUrl prop.
- The parity check applies app-v2's rule to apps.json on main and also
  compares the main app's label and URL; it retries a failed connection
  once. The workflow is renamed tools-menu-parity.yml.
- Exports renamed: policyEngineTools, getPolicyEngineTools,
  getPolicyEngineAppUrl, POLICYENGINE_APP_LABEL,
  DEFAULT_POLICYENGINE_APP_URL, PolicyEngineTool.
- Tests for the new rule; all 50 mutants are killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – policyengine-website — 4e6742b1 Deployed Oct 26, 2026 by vercel[bot]
Preview – policyengine-calculator-next — 4e6742b1 Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant