Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/tools-menu-parity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Tools menu parity

# The header's Tools menu copies the toolsMenu entries in policyengine.org's
# apps.json; see tests/layout/toolsMenuParity.network.test.ts. This runs when
# the copy or its check changes here, and weekly to catch a change on
# policyengine.org while nothing changes here. A failure outside a pull
# request opens an issue, or comments on the one already open.
on:
pull_request:
paths:
- src/layout/PolicyEngineTools.ts
- tests/layout/toolsMenuParity.network.test.ts
- .github/workflows/tools-menu-parity.yml
# How the test runs: the test:network script and the vitest exclude.
- package.json
- vitest.config.ts
push:
branches: [main]
paths:
- src/layout/PolicyEngineTools.ts
- tests/layout/toolsMenuParity.network.test.ts
- .github/workflows/tools-menu-parity.yml
# How the test runs: the test:network script and the vitest exclude.
- package.json
- vitest.config.ts
schedule:
- cron: "0 14 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
parity:
name: Tools menu parity
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write

steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Set up Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Compare with policyengine.org's apps.json
run: bun run test:network

- name: Open or update the drift issue
if: failure() && github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
title="Tools menu differs from policyengine.org"
body="The Tools menu parity check failed: $RUN_URL

src/layout/PolicyEngineTools.ts no longer matches the toolsMenu entries in app/src/data/apps/apps.json in PolicyEngine/policyengine-app-v2, or the main app's label or URL changed there, or those files could not be read. Update the copy here to match, so zone apps and policyengine.org show the same Tools menu."
number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title \"$title\"" --json number,title \
--jq "map(select(.title == \"$title\")) | .[0].number // empty")
if [ -n "$number" ]; then
gh issue comment "$number" --repo "$GITHUB_REPOSITORY" --body "Still failing: $RUN_URL"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body"
fi
1 change: 1 addition & 0 deletions changelog.d/tools-nav-menu.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a Tools menu to the PolicyEngine header, right after Research, built by the same rule as policyengine.org's: the PolicyEngine app first, then every calculator that works for any household under current law, sorted by label. The US menu lists CliffWatch, Coverage Compass, GiveCalc, the marriage calculator, the QBI deduction calculator, the SPM threshold calculator and the TANF calculator; the UK menu lists the marriage calculator, the student loan calculator and UK CliffWatch; other countries get no menu. Every row is a full page load, because the PolicyEngine app is on its own domain and each tool runs as its own app on policyengine.org. New exports: `policyEngineTools`, `getPolicyEngineTools`, `getPolicyEngineAppUrl`, `POLICYENGINE_APP_LABEL`, `DEFAULT_POLICYENGINE_APP_URL` and the `PolicyEngineTool`, `PolicyEngineToolLink` and `NavItemChildConfig` types. `getPolicyEngineNavItems` takes an optional app URL as its third argument, and `PolicyEngineHeader` and `PolicyEngineShell` an `appUrl` prop. `SheetContent` takes `overlayClassName` and `overlayStyle` for its dimmed overlay. `NavItemConfig` items and their dropdown rows take a new `external` flag that renders a plain link, bypassing `linkComponent` and `onNavigate`; rows inherit it from their item unless they set their own.
1 change: 1 addition & 0 deletions changelog.d/tools-nav-menu.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Header dropdowns now widen to fit their longest row, up to 28rem or the viewport width less 32px, instead of wrapping every row at 220px. A dropdown that would run past either edge of the viewport moves sideways to stay 16px inside it.
1 change: 1 addition & 0 deletions changelog.d/tools-nav-menu.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The mobile header menu now scrolls its links when they are taller than the screen, so Donate and other low items stay reachable on short phones, while the menu's title and close button stay in place. The menu and its dimmed overlay now sit above the sticky header instead of under it.
13 changes: 13 additions & 0 deletions demo/Demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import { SegmentedControl } from '../src/primitives/SegmentedControl';
// Layout
import { DashboardShell } from '../src/layout/DashboardShell';
import { Header } from '../src/layout/header';
import { PolicyEngineHeader } from '../src/layout/PolicyEngineHeader';
import { SidebarLayout } from '../src/layout/SidebarLayout';
import { SingleColumnLayout } from '../src/layout/SingleColumnLayout';
import { InputPanel } from '../src/layout/InputPanel';
Expand Down Expand Up @@ -1292,6 +1293,18 @@ export function Demo() {
</div>
</SubSection>

<SubSection title="PolicyEngine header">
{/* No overflow-hidden here: it would clip the dropdowns. */}
<div className="border border-border rounded-lg">
<PolicyEngineHeader
country={homeCountry}
countries={homeHeaderCountries}
onCountryChange={setHomeCountry}
styles={{ root: { position: 'relative' } }}
/>
</div>
</SubSection>


<SubSection title="Sidebar components">
<Card className="max-w-[280px]">
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"prebuild": "tsx scripts/generate-css.ts",
"build": "vite build && mv dist/ui-kit.css dist/styles.css && tsc -p tsconfig.build.json --emitDeclarationOnly",
"test": "vitest run",
"test:network": "VITEST_NETWORK=1 vitest run .network.test",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
Expand Down Expand Up @@ -139,6 +140,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"fast-check": "^4.10.2",
"jsdom": "^25.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
6 changes: 5 additions & 1 deletion src/layout/PolicyEngineHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { Header, type CountryConfig, type HeaderProps, type NavItemConfig } from './header';
import { DEFAULT_POLICYENGINE_APP_URL } from './PolicyEngineTools';
import {
DEFAULT_POLICYENGINE_BASE_URL,
getPolicyEngineCountrySwitchUrl,
Expand All @@ -17,6 +18,8 @@ export interface PolicyEngineHeaderProps
> {
country?: PolicyEngineCountryId;
baseUrl?: string;
/** The main app's origin, which the Tools menu's first row links to. */
appUrl?: string;
navItems?: NavItemConfig[];
countries?: CountryConfig[];
logoHref?: string;
Expand All @@ -26,6 +29,7 @@ export interface PolicyEngineHeaderProps
export function PolicyEngineHeader({
country = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
appUrl = DEFAULT_POLICYENGINE_APP_URL,
navItems,
countries = policyEngineCountries,
logoHref,
Expand All @@ -45,7 +49,7 @@ export function PolicyEngineHeader({

return (
<Header
navItems={navItems ?? getPolicyEngineNavItems(country, baseUrl)}
navItems={navItems ?? getPolicyEngineNavItems(country, baseUrl, appUrl)}
countries={countries}
currentCountry={country}
logoHref={logoHref ?? getPolicyEngineCountryUrl(country, baseUrl)}
Expand Down
13 changes: 11 additions & 2 deletions src/layout/PolicyEngineShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { cn } from '../utils/cn';
import { PolicyEngineFooter, type PolicyEngineFooterProps } from './PolicyEngineFooter';
import { PolicyEngineHeader, type PolicyEngineHeaderProps } from './PolicyEngineHeader';
import { DEFAULT_POLICYENGINE_BASE_URL, type PolicyEngineCountryId } from './PolicyEngineSiteConfig';
import { DEFAULT_POLICYENGINE_APP_URL } from './PolicyEngineTools';

export interface PolicyEngineShellProps {
children: ReactNode;
country?: PolicyEngineCountryId;
baseUrl?: string;
/** The main app's origin, which the header's Tools menu links to first. */
appUrl?: string;
showFooter?: boolean;
headerProps?: Omit<PolicyEngineHeaderProps, 'country' | 'baseUrl'>;
headerProps?: Omit<PolicyEngineHeaderProps, 'country' | 'baseUrl' | 'appUrl'>;
footerProps?: Omit<PolicyEngineFooterProps, 'country' | 'baseUrl'>;
className?: string;
mainClassName?: string;
Expand All @@ -21,6 +24,7 @@ export function PolicyEngineShell({
children,
country = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
appUrl = DEFAULT_POLICYENGINE_APP_URL,
showFooter = true,
headerProps,
footerProps,
Expand All @@ -29,7 +33,12 @@ export function PolicyEngineShell({
}: PolicyEngineShellProps) {
return (
<div className={cn('min-h-screen flex flex-col', className)}>
<PolicyEngineHeader country={country} baseUrl={baseUrl} {...headerProps} />
<PolicyEngineHeader
country={country}
baseUrl={baseUrl}
appUrl={appUrl}
{...headerProps}
/>
<main className={cn('flex-1', mainClassName)}>{children}</main>
{showFooter && (
<PolicyEngineFooter country={country} baseUrl={baseUrl} {...footerProps} />
Expand Down
84 changes: 74 additions & 10 deletions src/layout/PolicyEngineSiteConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { CountryConfig, NavItemConfig } from './header';
import type { FooterProps } from './Footer';
import {
DEFAULT_POLICYENGINE_APP_URL,
POLICYENGINE_APP_LABEL,
policyEngineTools,
} from './PolicyEngineTools';

export type PolicyEngineCountryId = 'us' | 'uk' | (string & {});

Expand Down Expand Up @@ -59,15 +64,75 @@ export function getPolicyEngineUrl(
return `${getPolicyEngineCountryUrl(country, baseUrl)}${normalizedPath}`;
}

/** A tool with its absolute link. */
export interface PolicyEngineToolLink {
slug: string;
label: string;
href: string;
}

/** A country's tools, sorted by label, with absolute links, or none. */
export function getPolicyEngineTools(
country: PolicyEngineCountryId = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
): PolicyEngineToolLink[] {
// Own keys only, so `__proto__` or `constructor` never finds a list.
// (Object.hasOwn would do, but Safari added it only in 15.4.)
const tools = Object.prototype.hasOwnProperty.call(policyEngineTools, country)
? (policyEngineTools[country] ?? [])
: [];
return tools.map((tool) => ({
...tool,
href: getPolicyEngineUrl(country, tool.slug, baseUrl),
}));
}

/** The main app for a country. */
export function getPolicyEngineAppUrl(
country: PolicyEngineCountryId = 'us',
appUrl = DEFAULT_POLICYENGINE_APP_URL,
) {
return `${normalizeBaseUrl(appUrl)}/${country}`;
}

function getPolicyEngineToolsNavItem(
country: PolicyEngineCountryId,
baseUrl: string,
appUrl: string,
): NavItemConfig | null {
const tools = getPolicyEngineTools(country, baseUrl);
// The main app never stands alone in the menu.
if (tools.length === 0) {
return null;
}

const mainAppHref = getPolicyEngineAppUrl(country, appUrl);
// The main app is on another domain and each tool is its own zone, so no
// app rendering this header can reach them with a client-side transition:
// every row is a full page load.
return {
label: 'Tools',
href: mainAppHref,
external: true,
children: [
{ label: POLICYENGINE_APP_LABEL, href: mainAppHref, external: true },
...tools.map(({ label, href }) => ({ label, href, external: true })),
],
};
}

export function getPolicyEngineNavItems(
country: PolicyEngineCountryId = 'us',
baseUrl = DEFAULT_POLICYENGINE_BASE_URL,
appUrl = DEFAULT_POLICYENGINE_APP_URL,
): NavItemConfig[] {
const navItems: NavItemConfig[] = [
const toolsNavItem = getPolicyEngineToolsNavItem(country, baseUrl, appUrl);
return [
{
label: 'Research',
href: getPolicyEngineUrl(country, 'research', baseUrl),
},
...(toolsNavItem ? [toolsNavItem] : []),
{
label: 'Model',
href: getPolicyEngineUrl(country, 'model', baseUrl),
Expand All @@ -76,6 +141,14 @@ export function getPolicyEngineNavItems(
label: 'API',
href: getPolicyEngineUrl(country, 'api', baseUrl),
},
...(country === 'us'
? [
{
label: 'Python',
href: getPolicyEngineUrl(country, 'python', baseUrl),
},
]
: []),
{
label: 'About',
href: getPolicyEngineUrl(country, 'team', baseUrl),
Expand All @@ -102,15 +175,6 @@ export function getPolicyEngineNavItems(
href: getPolicyEngineUrl(country, 'donate', baseUrl),
},
];

if (country === 'us') {
navItems.splice(3, 0, {
label: 'Python',
href: getPolicyEngineUrl(country, 'python', baseUrl),
});
}

return navItems;
}

export function getPolicyEngineFooterLinks(
Expand Down
58 changes: 58 additions & 0 deletions src/layout/PolicyEngineTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* A tool listed in the PolicyEngine header's Tools menu: a calculator that
* works for any household under current law. Research about one policy, bill
* or event stays out of the menu, however interactive it is.
*
* Each tool is its own zone on policyengine.org at `/<country>/<slug>`, so a
* link to one must be a full page load, never a client-side transition.
*/
export interface PolicyEngineTool {
/** Path segment after the country: the tool lives at `/<country>/<slug>`. */
readonly slug: string;
readonly label: string;
}

/** The Tools menu's first row, which links to the main app. */
export const POLICYENGINE_APP_LABEL = 'PolicyEngine app';

/** Where the main app lives; it serves `/<country>`. */
export const DEFAULT_POLICYENGINE_APP_URL = 'https://app.policyengine.org';

function freezeTools(
tools: PolicyEngineTool[],
): readonly PolicyEngineTool[] {
return Object.freeze(tools.map((tool) => Object.freeze({ ...tool })));
}

/**
* Each country's tools, sorted by label. A country with no entry has no
* Tools menu.
*
* policyengine.org builds the same menu from the `toolsMenu` labels in
* PolicyEngine/policyengine-app-v2's `app/src/data/apps/apps.json`, which
* ui-kit can't import. `tests/layout/toolsMenuParity.network.test.ts` fails
* when this copy, the main app's label or its URL differ from app-v2's, so
* change them when apps.json changes. The data is frozen because every
* header in a bundle shares it.
*
* This export may move: the plan is for app-v2 and ui-kit to read one list,
* after which this copy goes.
*/
export const policyEngineTools: Readonly<
Partial<Record<string, readonly PolicyEngineTool[]>>
> = Object.freeze({
us: freezeTools([
{ slug: 'cliffwatch', label: 'CliffWatch' },
{ slug: 'coverage-compass', label: 'Coverage Compass' },
{ slug: 'givecalc', label: 'GiveCalc' },
{ slug: 'marriage', label: 'Marriage calculator' },
{ slug: 'qbi-calculator', label: 'QBI deduction calculator' },
{ slug: 'spm-calculator', label: 'SPM threshold calculator' },
{ slug: 'tanf-calculator', label: 'TANF calculator' },
]),
uk: freezeTools([
{ slug: 'marriage', label: 'Marriage calculator' },
{ slug: 'uk-student-loan-calculator', label: 'Student loan calculator' },
{ slug: 'uk-cliff-watch', label: 'UK CliffWatch' },
]),
});
Loading
Loading