Skip to content

Add a Tools menu to the PolicyEngine header, built by policyengine.org's rule - #39

Merged
MaxGhenis merged 8 commits into
mainfrom
feat/tools-nav-menu
Oct 26, 2026
Merged

MaxGhenis merged 8 commits into
mainfrom
feat/tools-nav-menu

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Why

A JEC analyst found CliffWatch hard to reach from policyengine.org. PolicyEngine/policyengine-app-v2#1421 gave the website header a Tools menu, and PolicyEngine/policyengine-app-v2#1424 (merged as 3c536338c, live in production) replaced its hand-picked list with a rule. Zone apps don't use that header, though. They render ui-kit's PolicyEngineShell / PolicyEngineHeader, whose nav (getPolicyEngineNavItems) had no Tools item. So once a visitor is on one tool, the header can't take them to the others.

What

getPolicyEngineNavItems(country, baseUrl, appUrl?) now inserts Tools right after Research, built by policyengine.org's rule. Max's ruling: a tool works for any household under current law; research about one policy, bill, event or finding stays out, however interactive it is. The menu lists the main app first, then the country's tools sorted by label:

Country Rows
us PolicyEngine app → https://app.policyengine.org/us · CliffWatch → /us/cliffwatch · Coverage Compass → /us/coverage-compass · GiveCalc → /us/givecalc · Marriage calculator → /us/marriage · QBI deduction calculator → /us/qbi-calculator · SPM threshold calculator → /us/spm-calculator · TANF calculator → /us/tanf-calculator
uk PolicyEngine app → https://app.policyengine.org/uk · Marriage calculator → /uk/marriage · Student loan calculator → /uk/uk-student-loan-calculator · UK CliffWatch → /uk/uk-cliff-watch
any other no Tools item; the nav is unchanged
  • Tool hrefs are absolute on baseUrl, like the rest of the shared nav. The default, https://policyengine.org, 307-redirects to https://www.policyengine.org, where all ten tool pages return 200 today.
  • The main app's row uses a new appUrl, default https://app.policyengine.org. PolicyEngineHeader and PolicyEngineShell take it as a prop. The Tools item's own href is the main app too. A dropdown trigger renders as a button, so nothing links it today.
  • The UK menu links UK CliffWatch at /uk/uk-cliff-watch, never /uk/cliffwatch. Since PolicyEngine/policyengine-app-v2#1423, that path 308-redirects to /uk/uk-cliff-watch; before it, it showed the US app.
  • "All interactive tools" is gone, as on policyengine.org.

Every row is a full page load. The main app is on another domain, and each tool is its own zone. For example, the website rewrites /us/cliffwatch to cliff-watch.vercel.app/, which has no basePath. So a client-side router transition can't reach any of them. NavItemConfig items and dropdown rows gain an optional external flag. On desktop and mobile it renders a plain <a>, skips linkComponent and onNavigate, and doesn't call preventDefault. Rows inherit the flag from their item unless they set their own, and the new NavItemChildConfig type names a row. The Tools item and every row in it set it. Items without the flag behave as before.

Dropdown width and position. ui-kit's dropdown panel is absolutely positioned under a narrow trigger. With no width it shrank to minWidth: 220px, and longer rows wrapped: I measured the About rows' descriptions wrapping at 1200px, ui-kit's lg. Today's Tools labels fit in 220px; a longer one would wrap the same way. The panel now uses the width: max-content fix from PolicyEngine/policyengine-app-v2#1421, capped at min(28rem, 100vw - 32px) so a long description can't widen the page. A wider panel, centred under a trigger near the edge, could run off the viewport and add a horizontal scrollbar. So the panel now places itself to stay 16px inside the viewport, moving sideways from centred under its trigger (the left edge wins if it can't fit at all). It does this when it opens, and again whenever the window resizes or anything scrolls while it's open. It works from the trigger's position, so a placement never depends on the last one. The shift is set with left, which doesn't animate, so the panel never slides sideways and a measurement never catches it mid-slide. Under a scaled ancestor it converts the shift to the panel's own pixels. At 1280px, Tools measures 220px and About 344px, every row on one line.

Mobile sheet. Fixes the reviews found while checking the longer menu:

  • The sheet had no overflow rule. With the US Tools rows its content is taller than a 667px phone, so Donate sat below the screen. The list of links now scrolls on its own, under the sheet's title and close button, which stay in place.
  • The sheet and its dimmed overlay sat at z-50, under the sticky header's zIndex: 1000. On a page where the header is at the root, the header covered the sheet's title and close button and stayed undimmed. Both are now at 1001. SheetContent gains overlayClassName and overlayStyle props to set this.

New exports from @policyengine/ui-kit and /layout:

  • policyEngineTools: each country's {slug, label} entries, sorted by label.
  • getPolicyEngineTools(country, baseUrl): the same entries with absolute hrefs, typed PolicyEngineToolLink.
  • getPolicyEngineAppUrl(country, appUrl).
  • POLICYENGINE_APP_LABEL and DEFAULT_POLICYENGINE_APP_URL.
  • the PolicyEngineTool, PolicyEngineToolLink and NavItemChildConfig types.

The data is frozen and typed read-only, since every header in a bundle shares it. Its doc comment says the export may move once app-v2 and ui-kit read one list. The demo gallery gains a "PolicyEngine header" section with a country switcher covering US, UK and Nigeria.

Where the list lives: a copy in ui-kit, checked against apps.json

policyengine.org builds the menu from the toolsMenu: { label } field on entries in app-v2's app/src/data/apps/apps.json (app/src/utils/toolsMenu.ts for the calculator, website/src/lib/toolsMenu.ts for the website, which reads the same file through a symlink). ui-kit can't read that file:

  • It isn't published. apps.json is app-v2's data, inside a Next.js app and a Vite app, not a package.
  • app-v2 can't import from ui-kit either, yet. Its root, app/ and website/ package.json files all pin @policyengine/ui-kit to ^0.4.0, and a caret range on 0.x won't reach 0.13. Even after an upgrade, the website couldn't read data from the entries this PR adds to. ui-kit's build prefixes the main entry and /layout with "use client" (preserveClientDirectives in vite.config.ts). The homepage's HomeToolsPreview.tsx is a server component, imported by website/src/app/[countryId]/page.tsx, and a server component that imports a value from a "use client" module gets a client reference, not the data.

So ui-kit keeps a copy, in src/layout/PolicyEngineTools.ts, and a test fails when it drifts:

  • The check. tests/layout/toolsMenuParity.network.test.ts fetches app-v2's main copy of apps.json and applies app-v2's rule to it: entries with a toolsMenu label, by country, sorted with localeCompare(…, 'en'). It asserts toStrictEqual with ui-kit's copy: same countries, slugs, labels and order. It also reads MAIN_APP_LABEL and CALCULATOR_URL's default from both headers' sources (app/src/utils/toolsMenu.ts with app/src/constants.ts, and website/src/lib/toolsMenu.ts), and compares them with POLICYENGINE_APP_LABEL and DEFAULT_POLICYENGINE_APP_URL. It reports a failed fetch, a non-JSON response or a source pattern it can't find plainly, and retries a failed connection once.

  • What it can't see, as its doc comment says:

    • It applies its own copy of app-v2's rule rather than running app-v2's code. If app-v2 changes the rule itself (another filter, a different sort), this check can pass while the menus differ. app-v2's own website/src/__tests__/lib/toolsMenu.test.ts pins its side.
    • It reads CALCULATOR_URL's default in the code. A deployment that sets NEXT_PUBLIC_CALCULATOR_URL or VITE_CALCULATOR_URL would link the main app elsewhere, unseen.
    • A failure means the copy needs an update; the zones keep the old menu until a ui-kit release and each zone's bump and rebuild.
  • When it runs. Because it needs the network, bun run test skips *.network.test.ts files, and bun run test:network runs them. The tools-menu-parity.yml workflow runs it:

    • on pull requests and pushes to main that touch the copy, the test, the workflow, package.json or vitest.config.ts;
    • weekly on Mondays at 14:00 UTC, to catch a change on the website while nothing changes here;
    • on manual dispatch.

    A failure outside a pull request opens an issue titled "Tools menu differs from policyengine.org", or comments on the open one. The job has a 10-minute timeout and only contents: read and issues: write.

Invariants (property-tested with fast-check)

For every country C (including '', US, constructor, __proto__, toString and arbitrary strings), base URL B and app URL A (http or https, any domain, optional port, 0–3 trailing slashes):

  1. Gating. Tools appears exactly once if C is us or uk, and never otherwise. Inherited object keys never yield a menu, because the lookup checks own properties only (with hasOwnProperty, since Object.hasOwn needs Safari 15.4). The main app never stands alone in a menu.
  2. Position. Research is first, and Tools is second whenever it's present.
  3. Conservation. Removing Tools leaves exactly the 0.12.1 nav. This is a differential test against a copy of the previous implementation.
  4. Rows. The first row is "PolicyEngine app" at <A without trailing slashes>/C, and the Tools item's href is the same. Then come exactly C's tools in data order, each at <B without trailing slashes>/C/<slug> on B's origin, with no query, sorted by label. Every row and the item are external. Labels and hrefs are unique.
  5. Research stays out. No menu links the OBBBA explorer (obbba-households) or the bill tracker (bill-tracker), and the UK menu contains /uk/uk-cliff-watch and no /uk/cliffwatch path.
  6. Determinism and freshness. Repeated calls are deep-equal, and mutating returned tools or nav items doesn't leak into later calls.
  7. Shared data is immutable. The country map, each list and each entry are frozen.

The rendering tests cover desktop and mobile, with and without linkComponent and onNavigate:

  • Every Tools row, in order, is a plain link with the right href; the click isn't prevented, and onNavigate isn't called.
  • About rows still go through the link component and onNavigate.
  • The desktop panel and the mobile sheet close on a tool click.
  • Rows inherit external from their item, and a row's own flag wins, both ways.
  • appUrl and baseUrl reach the rendered links, from the header and from the shell.
  • The UK rows, no menu for ca, the panel's max-content width and cap.
  • The panel's position, with a mocked layout: centred when it fits or can't be measured; moved in from the right or left edge; the left edge kept when it's wider than the viewport; the shift in the panel's own pixels under a scaled ancestor; placed again on a resize or a container scroll while open; its listeners removed on close; measured afresh on each opening.
  • SheetContent's overlay props reach the overlay and nowhere else, and the overlay is unchanged without them.
  • The mobile sheet: the Tools rows in order, the scrolling list inside it (not the sheet itself, and not the title or close button) in a column it fills, and the sheet and overlay above the header. jsdom has no layout, so these check the wiring; the browser check below checks the scrolling itself.

Mutation check. I applied 78 mutants, and all 78 are caught.

  • 71 ran against the suite:
    • for external: dropping it on each code path, breaking inheritance or the row override on desktop and mobile;
    • for the panel: keeping it open after a click, dropping max-content or the width cap; for its position, ignoring either edge, letting the right edge win, measuring the panel instead of the trigger, no margin, measuring innerWidth, no resize or scroll listener, a scroll listener that doesn't capture, leaking either listener, dropping the unmeasured-panel guard, not applying the shift, ignoring the scale, animating left or all;
    • for the mobile sheet: reversing the rows or dropping the last; dropping the list's overflow, flex or minHeight; scrolling the whole sheet again; laying the sheet out in a row; dropping the sheet's or the overlay's z-index; SheetContent ignoring overlayClassName or overlayStyle;
    • for the lookup and links: dropping the own-property check; dropping, moving or un-flagging the main app row; reversing the tools; relative hrefs; ignoring or not normalizing appUrl in the function, the nav, the header prop or the shell prop; a wrong shell default; giving every country the US list; showing the main app alone;
    • for the data: unfreezing each level, using /uk/cliffwatch, swapping the order, changing a label, adding the OBBBA explorer, dropping a UK tool, changing the main app's label or URL.
  • 7 ran against the network check: a changed label, an extra tool, a dropped tool, a swapped order, a changed main app label or URL, and a one-line CALCULATOR_URL pattern, which can't read the website's two-line declaration.

Verification

  • bun run build && bun run test: 31 files, 346 tests pass, including the consumer-types harness against the built dist/. The fixture imports the new exports, NavItemChildConfig, PolicyEngineToolLink, the external fields, the header's and shell's appUrl props and SheetContent's overlayStyle.
  • bun run test:network: 3 tests pass against app-v2's main (3c536338c and later).
  • bun run typecheck: clean. actionlint on the workflow: clean.
  • In the demo, in the built-in browser:
    • At 1280px, the US Tools panel lists the eight rows above, each on one line and 40px tall, with the hrefs above, centred under its trigger. About's rows are on one line too.
    • At 1210px, with About moved next to the right edge, its panel ends at 1194px, 16px in; moved next to the left edge, it starts at 16px. Opening it again lands in the same place, put back it's centred again, and the page never scrolls sideways.
    • Opened next to the right edge at 1280px (ending at 1264px), then the window narrowed to 1240px while it stayed open: it moved to end at 1224px, 16px in, with no sideways scroll.
    • Under an ancestor with transform: scale(0.8), at 1240px, About's panel renders 276px wide (344px in its own pixels) and ends at 1224px, 16px in.
    • The mobile sheet lists Tools → PolicyEngine app and the seven US tools, in order.
    • Constrained to 667px tall, the sheet itself doesn't scroll; its list of links scrolls by 77px, and at 553px by 191px, each bringing Donate fully into view. The title and close button stay at the top throughout.
    • With a root-level element at zIndex: 1000 standing in for a zone's sticky header, the overlay covers it outside the sheet, and the sheet's title and close button are on top. At the old z-50, the stand-in covers the close button and stays undimmed.

Rollout: who needs to bump

Merging releases 0.13.0 to npm, because a fragment is added. For the census I mapped every zone rewrite in app-v2 to its repo: website/src/data/appZoneRoutes.ts, website/next.config.ts and website/vercel.json. I then checked each repo's header code, package.json and lockfile on its default branch.

45 zone repos render PolicyEngineShell with the default nav and need only a bump to ^0.13.0, except that the marriage zone also needs the country fix in the follow-ups for its UK page. All pin ^0.12.x, and a caret range on 0.x stops at 0.12, so none picks this up on its own. None passes linkComponent or onNavigate, so for current consumers external is a safeguard rather than a fix. The repos: 2024-election-dashboard, 2025-year-in-review, ACA-Calc, ai-inequality, april-fools-2026, Autumn-budget-local-area, california-wealth-tax, cancelling-fuel-duty-rise, cliff-watch, Coverage-Compass, crfb-tob-impacts, ctc-calculator, energy-price-shock, givecalc, keep-your-pay-act, llm-econ-beliefs, marriage, nics-exemption-inactive-employees, oregon-kicker-refund, policyengine-ads-dashboard, policyengine-scorecard, ri-ctc-calculator, salt-amt-calculator, scotland-income-tax-reform, scottish-budget-2026-2027, snap-district-map, spm-calculator, student-loan-calculator, tanf-calculator, uc-rebalancing, uk-2024-manifestos-comparison, uk-autumn-budget-dashboard, uk-cliff-watch, uk-energy-autumn-budget, uk-land-value-tax, uk-public-services-imputation, uk-salary-sacrifice-analysis, uk-spring-statement-2026, uk-two-child-limit-app, us-state-eitcs-ctcs, utah-2026-tax-changes, vance-harris-ctc-comparison, working-Americans-tax-cut-act-, working-parents-tax-relief-act, wv-sb392-tax-cut. Every tool but the QBI calculator is among them: cliff-watch, Coverage-Compass, givecalc, marriage (both countries), spm-calculator, tanf-calculator, student-loan-calculator and uk-cliff-watch.

Zones a bump won't reach, because they build their own nav:

  • qbi-visualizer, the QBI deduction calculator: ui-kit's low-level Header with a hand-written NAV_ITEMS in frontend/src/app/Shell.tsx, pinned ^0.9.0. It needs getPolicyEngineNavItems('us') as well as the bump.
  • obbba-household-by-household: SvelteKit, with a hand-written NAV_LINKS in src/routes/+layout.svelte.
  • policyengine-model: the model pages, with its own PEHeader.tsx.
  • state-legislative-tracker: an app header with no site nav.
  • household-api-docs: the /api and /python pages, with its own Header.jsx.
  • Seven UK dashboards with local PolicyEngineHeader.jsx copies: bus-fare-cap, electricity-vat-cut, free-childcare-reform, impact-iran-war-living-standards, uk-energy-reforms, uk-triple-lock and young-worker-nics.
  • Thirteen more zones showed no PolicyEngineShell or PolicyEngineHeader in the census. Several build their own header: child-poverty-impact-dashboard (AppV2Header.tsx); futa-wage-base-dashboard, missouri-income-tax-elimination and refundable-credit-conversion (frontend/components/Header.tsx); and policybench (SiteHeader.tsx). The others are policyengine-slides, snap-qc-sim, student-loan-visualisation, tutorial, nj-ctc-increase, policyengine-taxsim, south-carolina-2026-tax-changes and policyengine-be-demo; the follow-up should check each.

These need Tools added by hand, or a move onto PolicyEngineShell. Both are follow-ups and not part of this PR.

Follow-ups, not in this PR

  • Bump the 45 repos above to ^0.13.0 after release. Until then, and until each zone rebuilds, a zone keeps its old header, so a change to policyengine.org's list reaches zones only through a ui-kit release and those bumps.
  • Make the marriage zone's header follow its country. The website serves /uk/marriage from the same app as /us/marriage, with ?country=uk (website/src/data/appZoneRoutes.ts), but the zone's app/layout.jsx renders <PolicyEngineShell country="us">. So at /uk/marriage its header shows the US nav today, and after the bump it would show the US Tools menu.
  • Add Tools to the zones that build their own nav, above.
  • Mark the rest of the shared nav external. Research, Model, API, About and Donate also live in other zones, so a consumer that passes linkComponent would get a client transition that can't reach them. No current consumer passes one, so nothing is broken today.
  • Give ui-kit a server-safe data entry, or publish the list from app-v2, so the two stop keeping copies. Then delete the parity test.
  • UK CliffWatch's own metadata still gives its canonical URL as https://policyengine.org/uk/cliffwatch (frontend/app/layout.jsx), now a redirect to /uk/uk-cliff-watch. The session that shipped #1423 has proposed that fix.

🤖 Generated with Claude Code

Zone apps render ui-kit's header, which had no Tools item, so a visitor on
CliffWatch couldn't reach the OBBBA explorer from it. policyengine.org's own
header gained a Tools menu in PolicyEngine/policyengine-app-v2#1421; this
adds the same menu to getPolicyEngineNavItems, right after Research.

US: CliffWatch, OBBBA household explorer, State legislative tracker.
UK: UK CliffWatch (/uk/uk-cliff-watch). Others: no Tools item. Each list
ends with "All interactive tools". Rows carry a new `external` flag that
renders a plain <a>, skipping linkComponent and onNavigate, because each
tool is its own zone. Dropdown panels now size to their longest row
(width: max-content), as #1421 did, so "OBBBA household explorer" stays on
one line.

ui-kit keeps a copy of app-v2's featuredTools.json because app-v2 is on
ui-kit 0.4 and can't import it yet; a parity test reads app-v2's file and
fails when the two differ, on every PR and weekly.

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-ui-kit Ready Ready Preview Oct 26, 2026 2:06pm UTC

Request Review

MaxGhenis and others added 2 commits October 25, 2026 05:48
- Rows inherit `external` from their item unless they set their own, on
  desktop and mobile; the Tools item is external too. `NavItemChildConfig`
  names the row type.
- Cap dropdown width at min(28rem, 100vw - 32px).
- Let the mobile sheet scroll on short phones and sit above the sticky
  header.
- Freeze the featured-tools data and type it as read-only and partial.
- Move the network parity check out of `bun run test` into
  `bun run test:network`; its workflow runs on changes to the copy and
  weekly, and opens an issue on drift outside a pull request.
- Split the changelog into added, changed and fixed entries.
- Tests for each of the above; all 31 mutants are killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@MaxGhenis MaxGhenis changed the title Add a country-aware Tools menu to the PolicyEngine header Add a Tools menu to the PolicyEngine header, built by policyengine.org's rule Oct 26, 2026
MaxGhenis and others added 2 commits October 26, 2026 06:20
- Keep a dropdown panel 16px inside the viewport when it would run past
  either edge, measuring afresh on each opening.
- Scroll the mobile menu's links under a fixed title and close button,
  and lift the sheet's overlay above the sticky header too
  (SheetContent gains overlayClassName and overlayStyle).
- PolicyEngineShell takes appUrl; export PolicyEngineToolLink; avoid
  Object.hasOwn for Safari before 15.4.
- The parity check also reads the website header's MAIN_APP_LABEL and
  CALCULATOR_URL, and says what it can't see.
- Tests for each, including the mobile rows in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delta review found the clamp measured only when the panel opened, so
a resize while open could leave it off screen; that a scaled ancestor
made the shift the wrong size; and that a shift inside the animated
transform could be measured mid-slide.

- Place the panel from its trigger's position, on opening and on every
  resize or scroll while open, removing the listeners on close.
- Shift it with `left`, which doesn't animate.
- Convert the shift to the panel's own pixels under a scaled ancestor.
- Test SheetContent's overlay props and the sheet's column layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 26, 2026 14:22
@MaxGhenis
MaxGhenis merged commit 7ad64d8 into main Oct 26, 2026
5 checks passed
MaxGhenis added a commit that referenced this pull request Oct 26, 2026
Releases publish to npm with this repository's GitHub Actions identity (OIDC trusted publishing) instead of the stored NPM_TOKEN, which npm refused when #39's release ran (E404). Node is pinned to 24.21.0 (npm 11.19.0); workflow_dispatch allows a retry without a commit; the job runs only on main, one release at a time, and releases only a run's own commit while it is main's tip, handing a stale run over to a fresh one so npm's provenance names the published code. commit_and_push.sh rebases the release commit onto main before pushing.

npm's side (trusted publisher for push.yml, direct npm publish allowed) is still to be set up; until then a run that reaches publish fails there and leaves main unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis deleted the feat/tools-nav-menu branch October 26, 2026 17:22

This branch was successfully deployed

1 active deployment
Preview — 4e9098fe 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