Shared wizard primitives and a US household draft contract for PolicyEngine apps (cliff-watch, Coverage Compass, app-v2, and standalone calculators).
The package ships two surfaces:
- Wizard primitives — copy-neutral React primitives for step state, progress, navigation, option cards, and review screens. No household-specific code.
- US household model — a draft type that starts blank for state, age, and marital status (issue #1044), validation rules, URL serialization, state-filtered counties, and adapters to the PolicyEngine API V1 household payload.
bun add policyengine-household-wizard
# or
npm install policyengine-household-wizardReact 19 is a peer dependency.
If you want the default look, import the stylesheet once:
import 'policyengine-household-wizard/styles.css';The CSS uses ui-kit CSS variables (--primary, --border, --accent, …) when
they're present, with safe fallbacks otherwise. Apps already on
@policyengine/ui-kit will inherit the teal theme automatically.
import {
useWizardSteps,
WizardProgress,
WizardNavigation,
WizardOptionCard,
WizardReviewList,
createBlankDraft,
applyMaritalStatusChange,
validate,
toV1HouseholdPayload,
US_STATES,
getCountiesByState,
type USHouseholdDraft,
} from 'policyengine-household-wizard';
const steps = [
{ id: 'location', label: 'Location', isComplete: (d: USHouseholdDraft) => !!d.state },
{ id: 'marital', label: 'Marital status', isComplete: (d: USHouseholdDraft) => !!d.maritalStatus },
{ id: 'review', label: 'Review' },
];
function MyWizard() {
const [draft, setDraft] = useState(createBlankDraft());
const wizard = useWizardSteps({ steps, state: draft });
// ...render WizardProgress, step bodies, WizardNavigation
}See docs/api.md for wizard navigation and focus APIs and docs/migration.md
for migrating from each app's local wizard.
- Marital status, not filing status. The wizard asks users marital status in plain English; apps derive filing status (single, head of household, married filing jointly, …) in their own model adapters.
- No defaults for state, age, or marital status.
createBlankDraft()returns a draft with all three set tonull/empty. The wizard explicitly surfaces those fields rather than picking a quiet default like California or age 30. - State-filtered counties. Counties come from a bundled JSON generated
from PolicyEngine US metadata. Use
getCountiesByState('CA')to get the options for the chosen state. Refresh withbun run regenerate-counties. - Adapters, not a UI shell. Apps render their own form layouts and pass
the draft through
validate()andtoV1HouseholdPayload()(or their own app-specific adapter) at submit time. - No income field overrides another. An input on a PolicyEngine US total
such as
social_securityreplaces the sum of its components, so components sent alongside it are left out of the total.socialSecurityIncometherefore setssocial_security_retirementandssdiAmountsetssocial_security_disability; the model adds them. Pensions work the same way:publicPensionIncomeandprivatePensionIncomeset the components oftaxable_pension_income, which state rules read. A pension of unknown source (pensionIncome) is sent as private, and fields that share a variable are summed. Expenses set inputs too:mortgageInterestsetshome_mortgage_interest, notmortgage_interest, a computed total the federal interest deduction ignores (the tests keep it as a guarded total that no field may set). Tips are taxed as wages in PolicyEngine US, sotipIncomesetstip_incomeand is also added toemployment_income; with a listedtippedOccupationCode(TIPPED_OCCUPATIONS, from the final section 224 regulations) they can qualify for the 2025-2028 tip deduction. Property tests check the adapter against a snapshot of the PolicyEngine US variable graph (refresh it withbun run regenerate-variable-graph). Each total the adapter sets directly, and each variable that more than one field sets, is listed with its reason intests/us-household/v1PayloadInvariants.test.ts. - Computed variables go to everyone or no one. PolicyEngine stores each
person variable as one array covering everyone in the simulation, so an
input for one person on a variable the model computes gives everyone else
a stored default instead of the computed value:
ssisent for one adult zeroed the other adult's SSI. The adapter therefore sends computed variables for every person or no one, except employment income, self-employment income and long-term capital gains, which policyengine-us's Simulation moves onto input variables.kindis sent for everyone asis_tax_unit_dependent.isFullTimeStudentis sent as the inputis_full_time_college_student, and only from age 18, because the model counts ages 5 to 17 as K-12 students.ssiAmountis sent asssionly when every person has an amount;validate()asks for the missing ones, and an entered 0 is sent astakes_up_ssi_if_eligible: false. Property tests check this against the variable graph snapshot, which records which variables the model computes.
src/
primitives/ useWizardSteps, WizardProgress, WizardNavigation, …
us-household/ types, draft, validate, normalize, serialize, counties
adapters/ toV1HouseholdPayload — PolicyEngine API V1 envelope
data/ counties-by-state.json
tests/ vitest unit and round-trip tests
scripts/ generate-counties.ts, generate-variable-graph.ts
bun install
bun run test
bun run typecheck
bun run build
bun run test:live # adapter output against api.policyengine.org (network)Changes follow the towncrier convention used by @policyengine/ui-kit. Add a
fragment in changelog.d/ before opening a PR:
echo "Describe your change." > changelog.d/your-branch.added.mdFragment types: added (minor), changed (patch), fixed (patch),
removed (minor), breaking (major). The release workflow on main bumps
the version, builds the changelog, publishes to npm, and commits the bump.
See release setup and verification for npm trusted publishing.