Progressive Home UX Presentation-Only Config Playbook
Drag this file into a chat whenever you want to re-skin another Progressive Home config-driven step to match new Figma designs. It captures the load-bearing rules, the pattern we've proven on the NamedInsured ("About You") step, and the traps you'll hit if you miss them.
The non-negotiable constraint
Presentation only. HAL payload parity is required.
The JSON body we POST to Progressive's NextWorkflowState / CurrentWorkflowState endpoints must be byte-identical to what the generic renderer would have sent for the same user inputs. That means:
- No new HAL
Propertynames are invented. question.Property,question.LastAnswer,question.ValidValues,question.ShouldDisplay,question.ShouldDisplayControlare never mutated.- Hiding a field in the UI must not remove it from the submitted payload if Progressive requires it. Seed it from HAL
LastAnswerorFALLBACK_DEFAULTSand let react-hook-form carry it through. - Showing a new UX-only toggle is fine — it must live outside the form state and must never be serialized into the payload.
Any visible UX requested by design (rename labels, add descriptions, reorder fields, split one page into accordions) is allowed as long as it respects the above.
Where things live
| Concern | Location |
|---|---|
| Step dispatch by workflow node | apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/renderer/step-renderer.tsx |
| Shared form schema / defaults / sanitization | renderer/build-step-schema.ts, renderer/build-sanitized-payload.ts |
| Shared field renderer (inputs, selects, yes/no) | renderer/field-renderer.tsx |
| Step overrides (one bounded context per folder) | config-driven/step-overrides/<step>/ |
| Shell, routing, flow context | config-driven/shell/ |
| Step label overrides (sidebar stepper) | config-driven/shell/step-label-overrides.ts |
| Knockout page | knockout/ (under Progressive Home root) |
Top-level Routes wrapper (config-driven vs knockout) | apps/fastlane-portal/src/app/pages/carriers/progressive/home/index.tsx |
| Gateway HAL → form values extractor | libs/apis/carriers/progressive/src/lib/application/services/progressive-question-extractor.ts |
| Gateway HAL submission / refresh orchestration | libs/apis/carriers/progressive/src/lib/application/services/progressive-step.service.ts |
Proven override pattern (NamedInsured → About You)
Look at step-overrides/named-insured/ as the reference implementation. The shape we keep repeating:
step-overrides/<step>/
├── <step>-step.tsx # Orchestrator: FormProvider, state machine, submit
├── <step>-ui-store.ts # Zustand store for client-only gates (per-syncId)
├── field-groupings.ts # HAL Property → visible bucket mapping
├── label-overrides.ts # Render-time label/description overrides by Property
├── accordion-status.ts # Pure state machine helpers
├── <knockout-name>-knockout.ts # Client-side knockout navigation (if needed)
├── sections/
│ ├── accordion-card.tsx # idle / open / complete shell
│ ├── radio-card-toggle.tsx # Figma pill Yes/No (client-only)
│ ├── controlled-hal-radio-card.tsx # react-hook-form Controller over RadioCardToggle
│ ├── <section>-accordion.tsx # One per Figma accordion
│ └── ...
├── test-helpers/
│ ├── build-question.ts
│ └── build-<step>-config.ts
└── *.spec.{ts,tsx}
Step dispatch
Wire the override in step-renderer.tsx:
const <STEP>_WORKFLOW_NODE = '<WorkflowNodeId>';
if (stepConfig.workflowNode === <STEP>_WORKFLOW_NODE) {
return (
<>
<Progressive<Step>Step
stepConfig={stepConfig}
isSubmitting={isSubmitting}
onSubmit={submitStepWithData}
onRefresh={refreshWithData}
/>
{overlay}
</>
);
}
Orchestrator contract
The override component takes exactly what the generic renderer passes: stepConfig, isSubmitting, onSubmit, onRefresh. It owns:
useForm+FormProviderwithzodResolver(buildStepSchema(allQuestions))andshouldUnregister: false.buildDefaultValues(allQuestions, stepConfig.formValues, { syncId })for the initial form state.useEffectthat callsform.reset(defaults)when the serialized defaults change.- Accordion state machine + local client-only gates (Zustand-backed for persistence).
- Per-accordion
Nexthandlers that callform.trigger([...properties])before marking complete. - Final submit that rebuilds the merged payload via
sanitizeFormPayload({ ...stepConfig.formValues, ...form.getValues() }, allQuestions)— same sanitizer the generic renderer uses. id="config-driven-form"on the<form>so the page-levelProgressButtonsSave & Continue triggersform.requestSubmit()(seeshell/flow-shell.tsx).
Hiding fields but keeping them in the payload
- Omit the
Propertyfrom the accordion's field bucket infield-groupings.tssoConfigDrivenFieldnever renders it. - Rely on
FALLBACK_DEFAULTSinrenderer/build-step-schema.ts(or HALLastAnswer) to seed the form value. useForm({ shouldUnregister: false })keeps the key inform.getValues()even without a mountedController.sanitizeFormPayloadpasses it through at submit time.
Existing entries in FALLBACK_DEFAULTS:
DisclosureProvided: 'Y'
HasInternationalAddress: 'N'
MailingZipType: 'O'
PaymentOption: 'M05'
If UX hides a HAL-required field whose value doesn't come from DA pre-fill, add a fallback or surface it in the UI — otherwise Progressive will 400 with <field>: Required to continue.
Label / description overrides
Never mutate HAL question data. Build a label-overrides.ts keyed by HAL Property:
export function get<Step>LabelOverride(property: string):
{ label?: string; description?: string } | undefined;
Consume at render time in the accordion sections. The HAL payload is untouched.
Sidebar stepper label
To rename what the ProgressStepper shows for a workflow node, add an entry to shell/step-label-overrides.ts:
const STEP_LABEL_OVERRIDES: Readonly<Record<string, string>> = {
NamedInsured: 'About You',
// NextStep: 'New Label',
};
It's applied in flow-shell.tsx via resolveConfigDrivenStepLabel(item.Id, titleCase(item.Title)) and automatically flows into the "Previous / Save & Continue" subtitles.
Figma-style Yes/No
Progressive's design system uses pill radio cards (#dcffe8 accent, #017429 border) distinct from the utility EligibilityQuestion component. Two primitives live in step-overrides/named-insured/sections/:
RadioCardToggle— stateless, for client-only gates. Acceptsvalue: 'Y' | 'N' | ''andonChange.ControlledHalRadioCard— wrapsRadioCardTogglein a react-hook-formControllertied to a HAL question. FiresonRefreshfor properties inALWAYS_REFRESH_PROPERTIES(currently includesRecentlyMoved) so HAL reveals conditional questions when the user toggles.
Copy these into a new step override if you need the same visual language; if UX makes them truly shared, promote them to libs/ui/components.
Persistence: per-quote client gates
Client-only toggles (things like "is property same as mailing?", "do you have a mortgage?") need to survive navigation to later steps and back. Use a dedicated Zustand store per step override:
export const use<Step>UiStore = create<Store>()(
persist(
(set, get) => ({
...EMPTY_STATE,
ensureScope: (syncId) => {
if (!syncId) return;
const { syncId: current } = get();
if (current === syncId) return;
if (current === null) { set({ syncId }); return; }
set({ ...EMPTY_STATE, syncId });
},
reset: () => set({ ...EMPTY_STATE }),
// ...setters
}),
{
name: 'goosehead-progressive-home-<step>-ui',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({ syncId, ...gates }),
},
),
);
sessionStorage(not localStorage) — stays within the tab.syncIdscoping — a fresh Progressive session wipes the gates. CallensureScope(stepConfig.syncId)in auseEffecton mount.- Keep the store out of
useProgressiveHomeStore, which is documented as non-PII quote identity only.
On remount (user navigates away and back), derive the accordion state from the persisted gates with a helper like deriveInitialAccordionStatus(...). Guard against stale gates from a different syncId by reading the persisted syncId and returning empty values when it doesn't match the current step's.
Knockouts
When a step gates the flow with a client-side knockout (example: "no mortgage = can't bind"):
- Build a small helper module
<name>-knockout.tsthat exports the message, reasons, and ause<Name>KnockoutNavigation()hook usingnavigateToKnockout(navigate, state)fromknockout/knockout-utils.ts. - Handle BOTH paths:
- Immediate: when the user picks the knockout-triggering answer, navigate right away.
- Deferred: when the user answered earlier (value persisted), the page-level Save & Continue must also navigate. Add the check inside the step's submit handler BEFORE
form.handleSubmit's zod validation — an invalid HAL field would otherwise short-circuit the submit handler and make the button "do nothing" except scroll the page.
- Ensure the knockout route is reachable.
apps/fastlane-portal/src/app/pages/carriers/progressive/home/index.tsxmapsknockoutto theKnockoutPagecomponent; everything else falls through toConfigDrivenFlowShell. If you invent a new knockout route, add it there.
Accordion state machine essentials
step-overrides/named-insured/accordion-status.ts has the primitives. Each accordion is one of idle / open / complete.
markAccordionComplete(status, id)— marksidcomplete; auto-opens the next idle accordion.collapseOtherAccordions(status, id, completed?)— opensid, demotes otheropenaccordions. Pass acompletionmap so previously-completed accordions staycompleteinstead of falling back toidle(important when the user re-opens a completed accordion and then clicks into a later one).deriveInitialAccordionStatus(...gates)— maps persisted gate values to the right initial state on mount.
Testing patterns
- Golden-payload parity test: render the override, walk through the UI the same way the generic renderer would be filled, submit, and
expect(submittedPayload).toEqual(sanitizeFormPayload({ ...stepConfig.formValues, ...expectedFormValues }, allQuestions)). This is the load-bearing test that catches any payload regression. - Hidden-but-submitted assertions: query
screen.queryByTestId('field-wrapper-<Property>')to prove the field is absent from the DOM, and inspectonSubmit.mock.calls[0][0]to prove it's still in the HAL payload. - Client-only isolation: assert that prefix-tagged keys (
__samePropertyAsMailing,__hasMortgage, etc.) never appear in the submitted payload. - Persistence / rescope: mount → fill → unmount → remount with the same
syncId→ assert state restored; then remount with a differentsyncId→ assert state reset. UseuseStore.getState().reset()+sessionStorage.clear()inbeforeEach. - Do NOT call
vi.restoreAllMocks()in component-testafterEachblocks: it wipes the globalResizeObservermock set up invitest.setup.tsand breaks subsequent Radix-based renders.vi.clearAllMocks()is safe.
Traps we've already stepped on
- Empty required HAL field because the nested Question
LastAnsweris blank but the parent.List[0]has the real value. The gateway-sideextractFormValuesinprogressive-question-extractor.tsbackfills phone fromNamedInsured.PhoneNumbers.List[0]. If you hide another field whose HAL shape has the same quirk, extendbackfillFromStepDatawith an analogous helper. - "Save & Continue does nothing (just scrolls)". react-hook-form's
form.handleSubmit(handler)short-circuits when zod validation fails. If the form has HAL-required fields the user can't fill (because hidden) and the pre-fill didn't land,handlernever runs and the knockout/submit branches never fire. Either fix the pre-fill at the extractor level, or add an early-exit branch in anonSubmitthat runs beforeform.handleSubmit. - Knockout URL changed but the page didn't. The config-driven flow renders based on
stepConfig.workflowNode, not the URL. You must map the knockout path explicitly inhome/index.tsx's<Routes>. collapseOtherAccordionsdemoted a previously-complete accordion toidle. Always pass thecompletionmap.- ResizeObserver mock wiped mid-suite. See testing note above.
- Step-specific fallback default.
FALLBACK_DEFAULTSis global. If a different step needs a different value for the same property, that's a sign you should seed from per-step context (likedefaultCtx), not mutate the global map. - Footer
ProgressButtonsclipped on Samsung Galaxy S8+ (360px). The sharedProgressButtonsinlibs/ui/components/src/lib/progress-buttons.tsxusesflex-1 max-w-[170px] sm:max-w-[226px](notw-[…] shrink-0). The flex-cap keeps Figma's fixed widths at every viewport >= 380px (390/393/414/640/768/1024+ all hit the cap and produce pixel-identical layouts to a fixedw-[…]), while letting buttons compress on Samsung S8+ (360px) — where two 170px buttons +gap-2+ footerpx-4would otherwise add up to 380px and overflow the 360px viewport by 20px, clipping the right edge of Save & Continue. Any "Figma parity" pass that reintroducesw-[…] shrink-0will silently regress 360px devices; a regression test inprogress-buttons.spec.tsxpins the contract.
Quick starter kit for a new step
- Get the Figma node IDs for the page.
- Create
step-overrides/<step>/with the files from the Proven Pattern section. - Scope the
*-ui-storeto the step; decide which (if any) client-only gates need persistence. - Add the dispatch branch in
step-renderer.tsx. - Add a label override in
step-label-overrides.tsif the sidebar name changes. - Add the golden-payload parity test + the persistence roundtrip test.
- Update
apps/docs/docs/carriers/progressive/home/troubleshooting-config-driven.mdwith a "Step override" subsection documenting what's hidden, what's seeded from HAL, and where conditional refreshes fire. nx test fastlane-portal --run src/app/pages/carriers/progressive/home/config-driven/step-overrides/<step>andnx lint fastlane-portalbefore opening the PR.
Applied to: Property Details (ProductsHO)
Second production application of this playbook. Lives at apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/property-details/.
What changed
- Sidebar stepper label override:
ProductsHO: 'Property Details'inshell/step-label-overrides.ts. - Four accordions replace the generic pre/post-eligibility flat grid (the Dwelling Coverage accordion was removed in POD9-361 in favor of a silent backend RCE auto-adopt — see "Replacement Cost Estimate" below):
-
General Information — HAL-backed: Residence Type, "Currently under construction?" (
NewConstructionFlag), "Foreclosure/short sale?" (HomeForeclosureFlag), Purchase Date, Property Acreage, Purchase Price (PurchasePrice, conditional), Policy Start Date (PolicyEffectiveDate).PurchasePriceIS a HAL question onProductsHO(MVP_INTEGER,MaxLength: 10,Required: true); Progressive toggles itsShouldDisplaybased onHomeClosingRiskFlag === 'Y'(the new-purchase answer captured upstream by GaQ/RC1 in DA — Fastlane does NOT re-askHomeClosingRiskFlag; it is prefilled into HAL byCrnToProductsHoMapper.applyDisclosuresand round-trips throughLastAnswer). It renders viaControlledHalCurrencyInput(same component asDwellingCoverageValue), with PRD-mandated validation copy wired throughFieldErrorOverridesonbuildStepSchema("Please enter purchase price." for blank, "Please enter a valid purchase price." for0/non-numeric). The previous shim inprogressive-rc1-step-mapper.tsthat overwrotePurchasePricewithdwellingCoveragehas been removed — the user's answer now round-trips through HALLastAnswer.NewConstructionFlagis force-required client-side viaFORCE_REQUIRED_FIELDSeven though HAL reportsRequired: false, because on FAO this field renders as a checkbox (unchecked = implicitN) while Fastlane renders it as a Yes/No tile that has no implicit "no answer" state — without forcing required, the form would silently auto-default toNand submit a misleading answer. The CRN mapper does not prefillNewConstructionFlag(no equivalent CRN signal), so the user's tile selection is the sole source. -
Eligibility — client-only question list defined in
client-eligibility-questions.ts(14 yes/no questions +Animals on propertyselect). Every yes/no question is a knockout gate. Answering Yes to any question routes the user to/progressive/home/knockoutvianavigateToKnockout(...)with the matchingPROGRESSIVE_HOME_KNOCKOUT_REASONSkey and they never submit — so the "No" answers are stored in zustand (clientEligibility.yesNo) purely so the user does not re-answer them after navigating back. They are NEVER flushed to HAL from this step. The gateway'sProductsHoVerificationFlags.applyIfRequiredauto-appliesVerifyNoInelCond='Y'/VerifyNoUwCond='Y'on ProductsHO submit, which is all Progressive needs.The animals select is different: its real HAL home is
AnimalTypeon the AdditionalDetails step (AdditionalDetails_ProductSpecificInformation_List_0_Embedded_Questions_List_AnimalType, codes likeX=No,A=Akita). Because any non-"none" selection triggers a client-side knockout, the only value that ever survives inclientEligibility.animalsis'none'; a future AdditionalDetails step override reads that value out of the store and pre-fillsAnimalType: 'X'+OwnADog: 'N'without re-asking the user. When that select is implemented, keep its option codes aligned with Progressive HALValidValuesforAnimalType(FAO parity).Next only fires the HAL eligibility refresh after every question is answered No + animals='none'.
-
Exterior — Construction, Exterior Walls, Roof Design icon picker (
ControlledHalRoofDesign+ SVG Flat/Gable/Hip icons), Foundation, Garage, Deck Type 4-pill selector (ControlledHalPillSelecton HALDeckType.ValidValues), Swimming Pool yes/no.DeckSquareFootageappears when HAL unhides it.RoofMaterialandYearRoofInstalledare intentionally NOT surfaced — both are captured upstream in DA/GaQ ("Roofing Materials" and "Year Roof Constructed/Replaced") and prefilled into HAL byCrnToProductsHoMapper.applyConstructionfromcrn.dwelling.roofMaterialCd(translated viaROOF_MATERIAL_CD_TO_PROGRESSIVE) andcrn.dwelling.roofingImprovementYear. Their values round-trip via HALLastAnsweron every/step-config/submit(same upstream-capture pattern asHomeClosingRiskFlagon the General Information accordion). -
Interior — Full/Half Bathrooms, Bathroom Finish, Kitchen Finish, Cooling Type, Heating Type as HAL-backed selects. Interior is now the terminal accordion: clicking Save & Continue submits ProductsHO.
DwellingCoverageValueis still HAL-backed and round-trips viaLastAnswer(shouldUnregister: falsekeeps it in form state even though it is not rendered) so the carrier replacement-cost guard still triggers correctly on the backend.
-
Eligibility HAL refresh gate
Clicking Next on the Eligibility accordion calls onValidateEligibility(sanitizedPayload), which hits /progressive/home/step-config/validate-eligibility. Until HAL returns the Exterior/Interior questions with ShouldDisplay=true, those two accordions stay in idle (greyed out, header not clickable). The button shows Validating... while isValidatingEligibility=true.
"Eligibility has been validated" is tracked by a single signal: usePropertyDetailsUiStore.confirmedAccordions.eligibility, scoped by syncId. The orchestrator never infers this from HAL-prefilled LastAnswer or ShouldDisplay values — those are carrier-side prefill heuristics, not evidence that the user reviewed the client-side knockout gate. A remount restores the post-eligibility accordions because the confirmation flag is persisted in session storage.
Hidden-but-submitted fields
VerifyNoInelCond/VerifyNoUwCond— Progressive's master eligibility checkboxes are not surfaced. The gateway serviceProductsHoVerificationFlags.applyIfRequiredforces them to'Y'on submit when the workflow node isProductsHO. NoFALLBACK_DEFAULTSentry is needed.- Per-concern HAL flags (
TrampolineFlag,IneligibleElectricalSystemFlag,PoolFenceFlag, etc. — 20+ properties enumerated inELIGIBILITY_HAL_PROPERTIES) are round-tripped viashouldUnregister: falseusing HALLastAnswer. They never render in the UI because the Figma design replaces them with the client-side question list.
Client-only gates
confirmedAccordions—Record<AccordionId, boolean>inproperty-details-ui-store.ts(session-storage). Authoritative source of truth for "user has clicked Next on this accordion". Every accordion'scompletestate is derived from this map only; HAL prefill is never treated as confirmation.clientEligibility.yesNo/clientEligibility.animals— the 14 discrete Figma yes/no questions + the animals select. These are entirely client-side — they never appear in the sanitized HAL payload (sanitizeFormPayload only preserves entries whose key matches a HALPropertyname; theclientEligibilityshape lives in the UI store, not the react-hook-form state).deckKind— reserved for UX toggles that do not map 1:1 to HAL values. Today the Deck 4-pill control goes directly through HALDeckTypeviaControlledHalPillSelect.
Terminal accordion commit (Interior)
snapshotAccordion(id) writes the per-accordion field-value snapshot AND the per-accordion completion flag in the same call (mirrors additional-details-step.tsx#snapshotAccordion). Every explicit user-commit gesture — per-card Next, valid chevron-collapse in handleToggle, valid sibling demotion in handleToggle, walk-then-navigate predecessor commit in advanceFromCurrentAccordion, and terminal Save & Continue in handleFormSubmit — therefore writes both at once.
The Interior accordion has no per-card Next button; the page-level Save & Continue is the only commit path, and it intentionally keeps Interior visually open while the page navigates away. Without snapshotAccordion writing the completion flag itself, Interior would never reach accordionStatus.interior === 'complete', the useEffect mirror would never fire, and a remount (back nav / refresh / backend redirect to ProductsHO) would re-open Exterior — the last accordion the user actually saw flip to complete — instead of Interior.
The same useEffect that mirrors accordionStatus[id] === 'complete' into the store is still present (defensive + needed for the eligibility accordion, which has no snapshotAccordion path because its values live in clientEligibility).
Post-eligibility accordion locking
The Exterior/Interior accordions stay idle (greyed, non-interactive) until the user advances past the eligibility gate for the current syncId. A returning user (same syncId) skips re-confirming; a new quote (syncId changes) wipes the per-quote UI store and the user walks through the gate again.
The orchestrator never demotes an accordion from complete back to idle when re-computed; that was the source of a bug where post-eligibility accordions showed green checks on mount then reverted to idle when the user clicked back into General Information.
Replacement Cost Estimate — silent backend auto-retry (POD9-361)
Per POD9-361 the user-facing Dwelling Coverage UI is removed entirely. After Interior Save & Continue, Progressive recomputes RCE from the General Info / Exterior / Interior answers and returns it as DwellingCoverageValue on the next HAL state. There is no longer:
- A Dwelling accordion on Property Details.
- A "Replacement Cost Confirmation" modal asking the user to accept Progressive's suggested value.
- A
confirm-replacement-costendpoint oruseConfirmReplacementCosthook. - An RCE-mismatch knockout / "Talk with an agent" handoff.
Instead, when the ProductsHO submit returns ASI00001 DwellingCoverageValue edits, progressive-step.service.ts#retryWithCarrierRce silently re-submits with Progressive's suggested RCE (one-shot recursion guard), so the next HAL state always lands on HouseholdMembers with formValues.DwellingCoverageValue reflecting the carrier-adopted RCE. The Coverages page reads the final Coverage A from the new HAL state.
Tests
property-details-step.spec.tsx— four-accordion render (Interior is terminal), pre/post-eligibility idle gating, eligibility HAL call,Validating...button state, golden-payload parity againstsanitizeFormPayload(still pinsDwellingCoverageValueround-trips via HALLastAnswereven though it is no longer rendered), persistence roundtrip, syncId reset.accordion-status.spec.ts— 4-id state machine primitives.field-groupings.spec.ts— property-set coverage for the four rendered buckets + visibility helpers.label-overrides.spec.ts— Figma copy, bullet lists, unknown-property safety.property-details-ui-store.spec.ts— persistence,ensureScopesemantics,reset.progressive-step-rce-auto-retry.spec.ts(backend) —submitStepRawsilently re-submits ProductsHO with Progressive's RCE onASI00001, returning{ advanced: true, ... }with nopendingConfirmation.
Applied to: Household Members (People / HouseholdMembers)
Third production application of this playbook. Lives at apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/household-members/.
What changed
- Sidebar stepper label overrides:
People: 'Household Members'andHouseholdMembers: 'Household Members'inshell/step-label-overrides.ts(both Progressive workflow aliases resolve to the same label). - Three accordions replace the generic per-driver grid:
- Primary — driver 0. Renders
FirstName,MiddleInitial (Optional),LastName,Suffix (Optional),DateOfBirth,Gender,MaritalStatus.Relationshipis hidden in the UI because the primary insured is always the applicant; the HAL value still round-trips viashouldUnregister: falseand the HALLastAnswer(typically'I'). - Secondary — conditional on whether GAQ pre-populated a spouse:
- No spouse from GAQ → renders a yes/no card "Do you have a secondary person applying with you?". Answering Yes triggers the existing
onAddHouseholdMembermutation so Progressive createsDrivers.List[1]; the newly created fields then render inside this same accordion. - Spouse from GAQ → renders the full member form pre-filled with driver 1's HAL data (First/Middle/Last/Suffix/DOB/Gender/MaritalStatus/Relationship). Clicking Next persists
hasSecondaryPerson='Y'so a remount does not dump the user back onto the Primary accordion.
- No spouse from GAQ → renders a yes/no card "Do you have a secondary person applying with you?". Answering Yes triggers the existing
- Other Household Members — yes/no card "Would you like to add another household member to your policy?". Yes reveals a list of every driver at index ≥ 2 (or ≥ 1 when there is no spouse), each with the same member form + a per-row Remove button. The Figma "Number of additional members" dropdown is deliberately omitted — adding members goes through our existing
onAddHouseholdMember/onRemoveHouseholdMemberHAL mutations.
- Primary — driver 0. Renders
Spouse-from-GAQ detection (spouse-detection.ts)
Primary signal is the Redis-backed session drivers list (useSessionDrivers()). GAQ spouses land there with spouse === true or relationship === 'Spouse'. When the session query has not yet settled (first render, rehydrate), we fall back to HAL-only detection: Drivers.List[1] exists AND its Relationship LastAnswer is 'S' or 'SP' (the two codes the gateway's mapHouseholdMembers emits).
Hidden-but-submitted fields
Drivers.List[0].Relationship— not rendered on Primary. Carried through the form state viashouldUnregister: falseand seeded from HALLastAnswer. The gateway'sprogressive-rc1-step-mapper.tsalso defensively sets'I'for the primary driver.- Fields from Progressive that do not match
MEMBER_FIELD_PROPERTIES(e.g. disabled HAL flags, any future additions) — untouched; they round-trip via HALLastAnswer.
Client-only gates (household-members-ui-store.ts)
sessionStorage, scoped by syncId:
hasSecondaryPerson: 'Y' | 'N' | null— answered by the no-spouse-from-GAQ Y/N toggle, or set to'Y'implicitly when the spouse-from-GAQ path clicks Next (so remounts restore the same "secondary confirmed" state for both paths).hasAdditionalMembers: 'Y' | 'N' | null— answered by the Other accordion Y/N toggle.
Neither key appears in the submitted payload (guarded by the "does not serialize client-only gate keys" test).
Payload parity
The form uses flat Drivers.List[n].<Property> keys as react-hook-form names. On submit the orchestrator calls buildHouseholdPayload which runs each value through sanitizeQuestionValue. The gateway's overlayFlatFormData + applyIndexedDriverField pair (in progressive-hal-payload-builder.ts) recognizes these indexed keys and maps them into the HAL nested driver list. The parity test renders the step, walks the three accordions, submits, and asserts submittedPayload === buildHouseholdPayload(expectedFormValues, buckets) so any regression in sanitization or key building fails loudly.
Note: this step cannot reuse the standard sanitizeFormPayload parity check used by NamedInsured / PropertyDetails because sanitizeFormPayload keys by flat HAL Property names and silently drops indexed keys.
Tests
household-members-step.spec.tsx— three-accordion render, conditional Secondary (spouse-from-GAQ vs Y/N gate), Add/Remove wiring, golden-payload parity againstbuildHouseholdPayload, hidden-but-submitted Relationship assertion, persistence roundtrip (via store inspection because the collapsed Other body does not expose its toggle).accordion-status.spec.ts— 3-id state machine primitives +deriveInitialAccordionStatusacross each persisted-gate combination.field-groupings.spec.ts— driver index parsing, indexed rhf-key shape, primary vs additional filtering, deduplication across multiple Drivers.List sections.label-overrides.spec.ts— Figma copy + unknown-property safety.spouse-detection.spec.ts— session-authoritative + HAL fallback paths (covers both'S'and'SP'codes).household-members-ui-store.spec.ts— persistence,ensureScoperescoping,reset.
Applied to: Additional Details (AdditionalDetails)
Fourth production application of this playbook. Lives at apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/additional-details/.
What changed
- No sidebar stepper label override —
titleCase('AdditionalDetails')already yields "Additional Details". - Three accordions replace the generic grouped grid:
- Insurance History — HAL-backed selects rendered with Figma copy:
PriorInsurer,PriorLiabilityLimitsType,WeatherReportedClaimsCount,NonWeatherReportedClaimsCount,YearsClaimFree. - Household Information —
NumberResidentsInHouse,ChildrenResidentCountas selects;OccupancyTypeas a 2-up pill selector (ControlledHalPillSelect);OwnADogandPaperlessPreferenceas Figma Yes/No pill radio cards (ControlledHalRadioCard);AnimalTypeas a select. - Discounts —
AnyResidentsSmoke,AgencyUmbrellaPolicy,SecuredSubdivisionas Yes/No pill radio cards;PackagePolicyas a select;BurglarProtection,FireProtection,WaterLeakProtectionas single-select pill rows (ControlledHalPillSelect).
- Insurance History — HAL-backed selects rendered with Figma copy:
The Discounts accordion has no Next button — the page-level ProgressButtons Save & Continue is the terminal action, per Figma.
Hidden-but-submitted fields
HAL exposes these properties on AdditionalDetails but Figma does not render them. The gateway's progressive-rc1-step-mapper.ts seeds each with 'N' on RC1 submit, so HAL returns them with LastAnswer already populated. useForm({ shouldUnregister: false }) carries the values through sanitizeFormPayload unchanged. No FALLBACK_DEFAULTS additions needed.
WoodburningStoveFlag,OpenFoundation,IntendEsign,HomeUpdate,AcvLossSettlement
Deliberately omitted
AutoPolicy(Progressive auto policy #). Figma shows this field inside the Discounts accordion, but HAL asks for it on the PointOfSale step (not AdditionalDetails). Rendering it here would require cross-step plumbing (capture client-side, pre-fill on PointOfSale). The current override leaves it to the existing PointOfSale step to avoid that coupling.
Client-only UI store (additional-details-ui-store.ts)
sessionStorage, scoped by syncId, tracking only accordion-completion gates so a remount restores the right open-accordion + checkmark state:
insuranceHistoryConfirmed: boolean— set totruewhen the user clicks Next on Insurance History.householdConfirmed: boolean— same pattern for Household Information.discountsConfirmed: boolean— set at submit time.
None of these keys appear in the submitted payload (asserted by the "does not include any client-only gate keys" test).
Payload parity
The orchestrator uses the generic sanitizeFormPayload({ ...stepConfig.formValues, ...form.getValues() }, allQuestions) path — same sanitizer the generic renderer uses. The parity test renders the step, walks the three accordions, submits, and asserts:
expect(submittedPayload).toEqual(
sanitizeFormPayload({ ...stepConfig.formValues, ...expectedFormValues }, allQuestions),
);
Reused primitives
AccordionCardidle/open/complete shell copied fromnamed-insured/property-detailsrather than promoted — scope kept tight.RadioCardToggle+ControlledHalRadioCard— copied. Same Figma pill language (#dcffe8/#017429).PillSelect+ControlledHalPillSelect— copied fromproperty-details.FigmaLabeledField— step-local copy that reads fromadditional-details/label-overrides.ts(instead ofproperty-details/label-overrides.ts).
Tests
additional-details-step.spec.tsx— three-accordion render, Next gating per accordion, golden-payload parity againstsanitizeFormPayload, hidden-field round-trip assertions (WoodburningStoveFlag,OpenFoundation,IntendEsign,HomeUpdate,AcvLossSettlementabsent from DOM, present in payload with'N'), client-only gate exclusion, persistence roundtrip +syncIdrescope.accordion-status.spec.ts— 3-id state machine primitives +deriveInitialAccordionStatusacross each rehydration path.field-groupings.spec.ts— Figma-ordered buckets,HIDDEN_PROPERTIESexclusivity,classifyPropertydefault =hidden.label-overrides.spec.ts— Figma copy + unknown-property safety + no override for hidden fields.additional-details-ui-store.spec.ts— persistence,ensureScoperescope,reset.
Applied to: Coverages (CoveragesHO)
Fifth production application of this playbook. Lives at apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/coverages/.
What changed
- Sidebar stepper label override:
CoveragesHO: 'Coverages'inshell/step-label-overrides.ts(replacestitleCase('CoveragesHO')= "Coverages Ho"). - Package selector card row at the top (Figma 2288-24129) renders one card per HAL
SelectedPackage.ValidValueplus a synthesizedCustomcard bound to the HAL value'CU'. Clicking any card dispatchesonUpdateCoveragePackage({ ...sanitized, SelectedPackage: value, PackageType: value })through the same pipeline the generic renderer uses — the backend'salignPackageTypeWithSelectionalready accepts'CU'as a valid LastAnswer. - Four accordions replace the generic CoveragesHO flat grid:
- Property Coverages — read-only
DwellingCoverage+ReplacementCostEstimatecurrency panels at the top (pulled from HALLastAnswer, never user-editable on this page), then HAL-select rows with icon + description + helpful-hints info icon forOtherStructures,PersonalProperty,LossOfUse. The info icons open the shared right-rail Helpful Hints drawer (see "Helpful Hints Drawer (Property Details + Additional Details + Coverages)" below). Renders a live Figma chip"10% = $88,000 coverage"next to Other Structures by multiplying the selected percentage against the HAL Dwelling amount — entirely a render-time computation, zero HAL mutation. - Liability Coverages — icon rows for
PersonalLiability+MedicalPaymentsLimit. - Deductibles — teal "What does my deductible mean?" callout, followed by a client-only pill row "How would you like wind/hail roof claims to be settled?" (values
RC/RMPS), then icon rows forWindHailDeductible+AllOtherPerils. The Roof Claims Settlement value lives incoverages-ui-storeonly — Progressive's HAL does not expose a discrete property for it on CoveragesHO. - Add-on Coverages — 11 HAL-driven rows (
GolfCartPhysicalDamageLiability,IncreaseJewelryWatchesFur,BlanketScheduledJewelry,IncreasedReplacementCost,HiddenSeepageOrLeakage,LossAssessment,OrdinanceLaw,WaterBackupSumpLimit,MatchUndamagedSidingRoofing,HobbyFarmingFlag,MoldBuyBack) plus a bottom checkbox cluster (BuriedUtilityLinesOptionCode,PersonalPropertyReplacementCost,LimitedFoundationAndSlabBuyback,LimitedWaterDamage,PersonalInjury,SpecialPersonalProperty,EquipmentBreakdown). Rows with more than a handful of options (Hidden Seepage, Loss Assessment, Ordinance/Law, Water Backup, Matching Siding, Mold Buyback, Increased Replacement Cost, Hobby Farming) render as Figma pills; Blanket Scheduled Jewelry renders as a currency input; the rest are dropdowns. Like Discounts on AdditionalDetails, this accordion has no Next button — the page-levelProgressButtonsSave & Continue is the terminal action.
- Property Coverages — read-only
- Title prefixing (Figma 2358-33620): each accordion header reads
${package}: ${base}when a package is selected. The prefix strips trailing" Package"fromSelectedPackage.ValidValue.Textso"HomeShield Package"→"HomeShield: Property Coverages". When HAL returns'CU', the prefix is"Custom". WhenLastAnsweris empty, the prefix is omitted entirely.
Hidden-but-submitted fields
HAL exposes these properties on CoveragesHO but the Figma does not render them. useForm({ shouldUnregister: false }) carries the values through sanitizeFormPayload unchanged. No FALLBACK_DEFAULTS additions needed — Progressive already populates each with LastAnswer before the first CoveragesHO render.
PackageType(mirrored fromSelectedPackageby the backend'salignPackageTypeWithSelection)IsPackagingFlag(server-derived)SessionId,PaymentOption,CheckWithdrawDay(bill-plan fields, out of scope for first pass;PaymentOptionalready round-trips via the globalFALLBACK_DEFAULTS)HomeComputerCoverage,EarthquakeFoundationType,AddWaterBackupSumpLimit(state-specific HAL fields)ItemizedScheduledPersonalProperty,Item,Quantity,Value(scheduled personal property sub-workflow — deferred to a follow-up)Rerate(server-side flag)
All enumerated in field-groupings.ts → HIDDEN_PROPERTIES.
Deliberately client-only
- Roof Claims Settlement pill row — Progressive's HAL does not expose a discrete property matching the Figma "Replacement Cost / Roofing Materials Payment Schedule" question, so the value is stored in
coverages-ui-store.tsand NEVER serialized. If a future HAL update exposes the property, replaceRoofClaimsSettlementRowwith aControlledHalPillSelectand deleteroofClaimsSettlementfrom the store. A test asserts the key never appears in the submitted payload. - Compare Packages modal (Figma 2327-30527) — scaffolded as a placeholder (
sections/compare-packages-modal.tsx). The side-by-side comparison matrix is left as a fast-follow because Progressive does not expose a pre-computed comparison table in HAL; populating it correctly requires either hard-coded copy parity-tested against the FAO portal or extra per-package fetches.
Scheduled Personal Property & Bill Plan
Deliberately out of scope for this override. The generic StepForm path used to render both; the new ProgressiveCoveragesStep does not. Items previously scheduled via the sub-workflow are still round-tripped through HAL because ItemizedScheduledPersonalProperty + PersonalProperties.List[*] are in HIDDEN_PROPERTIES and not cleared — we simply don't re-surface the manager UI on this page. Bill plan will get its own Figma page.
Client-only UI store (coverages-ui-store.ts)
sessionStorage, scoped by syncId:
committedAccordions: Record<AccordionId, boolean>— one persisted flag per accordion (propertyCoverages/liability/deductibles/addOns). Set bymarkAccordionCommitted(id), idempotent on repeat calls. The orchestrator records a commit in exactly two places — see "Accordion commit/rehydration contract (POD9-387)" below for what does and does NOT count as a commit.roofClaimsSettlement: 'RC' | 'RMPS' | null— client-only Wind/Hail roof claims pill row.
Neither key appears in the submitted payload (asserted by a test).
Accordion commit/rehydration contract (POD9-387)
What counts as a commit (writes committedAccordions[id] = true):
- The in-card
Nextbutton on Property / Liability / Deductibles when the accordion's required fields validate. Failed validation bails before commit. - The page-level
Save & Continuewalk: when the handler advances a predecessor that is stillidle, it commits only aftervalidateAccordion(predecessor)returnstrue. - The page-level
Save & Continueterminal submit:addOnsis committed (and flipped tocomplete) immediately beforeonSubmit(sanitizedPayload)runs.
What explicitly does NOT commit:
- Editing or pre-populating field values.
- Expanding / collapsing an accordion via the chevron (the chevron is a pure presentation toggle).
- Failed validation in any of the three commit paths above.
Rehydration map (deriveInitialAccordionStatus(committed) in accordion-status.ts):
The helper restores the same accordion the user was looking at right before the refresh — that is, the auto-advanced successor of the last committed accordion. Each Next click commits its own accordion AND auto-opens the next one in-session, so on reload we collapse every committed accordion to complete and open the slot the user would naturally have been editing. Truth table:
| Persisted commits | Initial accordion display map |
|---|---|
| none | {property: open, liability: idle, deductibles: idle, addOns: idle} (= INITIAL_ACCORDION_STATUS) |
propertyCoverages | {property: complete, liability: open, deductibles: idle, addOns: idle} |
propertyCoverages, liability | {property: complete, liability: complete, deductibles: open, addOns: idle} |
propertyCoverages, liability, deductibles | {property: complete, liability: complete, deductibles: complete, addOns: open} |
| all four | {property: complete, liability: complete, deductibles: complete, addOns: open} — the user kept addOns open while submitting; back-nav rehydrates the same open view. (Same shape as the three-commit case because the terminal accordion has no successor to advance to; the only difference is whether addOns itself was persisted.) |
non-contiguous (e.g. propertyCoverages + addOns) | Keyed off the highest committed index. The orchestrator does not produce this map in normal flow (commits are sequential), but the helper degrades deterministically. |
addOns accordion has no special "terminal" lockdown. It behaves like every other accordion (matching property-details Interior):
state === 'open'→ body shown, chevron interactive. Clicking it validates and collapses tocomplete(orincompleteif invalid). Clicking another accordion's chevron auto-collapsesaddOnsviaopenOnlyAccordion.state === 'complete'→ body collapsed, header shows the check icon + "Complete" label, chevron interactive (clicking re-expands). Standard non-terminal complete.state === 'idle'→ locked, opacity-30, headerdisabled(only reachable on first mount before Property is committed).
Save & Continue is non-collapsing for addOns. When the user clicks Save & Continue with addOns open, the page submits without flipping the accordion's visible state — the user kept it open on purpose, and the page is about to navigate away regardless. commitTerminal only writes the persisted commit flag; it does not call setAccordionStatus. If the user manually collapsed addOns to complete before submitting, the submit also leaves that state alone.
incomplete is in-session only. It is reachable when the user opens an accordion, enters invalid input, and collapses it via the chevron — so the visible cue ("Incomplete") survives within the active mount. It is never serialized: a reload erases the incomplete state because nothing was committed, and deriveInitialAccordionStatus only emits idle | open | complete. The chevron on a true incomplete accordion stays interactive in-session because it is still the active accordion; locked future accordions never reach incomplete.
Diagnostic pattern. "I committed Property Coverages but on reload the page opens at addOns." → check that nothing else is calling markAccordionCommitted(id) for accordions later than the user actually completed. The store is the only mutator; only the in-card Next handlers (post-validation), the walk-then-submit predecessor branch (post-validation), and the terminal addOns submit branch should call it. A non-contiguous commit map is always a bug somewhere in this triplet, not in the helper.
"I clicked Next on Property and refreshed and the page opens at Property again instead of Liability." → check that deriveInitialAccordionStatus is opening the successor of lastCommittedIdx (i.e. lastCommittedIdx + 1), not lastCommittedIdx itself. The user's expectation tracks the in-session view: clicking Next on Property auto-advances to Liability, and refresh must restore that same Liability-open view. If you see Property opening on refresh, the derivation is off-by-one (the literal AC reading) and needs to use the successor.
Payload parity
The orchestrator uses the generic sanitizeFormPayload({ ...stepConfig.formValues, ...form.getValues() }, allQuestions) path. The parity test renders the step, walks all four accordions, submits, and asserts:
expect(submittedPayload).toEqual(
sanitizeFormPayload({ ...stepConfig.formValues, ...expectedFormValues }, allQuestions),
);
Package update flow
The package-card click path reuses the existing /progressive/home/step-config/update-package mutation exposed on flow-context as updateCoveragePackage + isUpdatingPackage. handlePackageSelect guards on isUpdatingPackage to prevent double-dispatch, and the PackageUpdateOverlay in step-renderer.tsx keeps rendering on top of the override during the mutation.
Package-driven fields are normalized before the request so the new package's defaults take precedence over the user's prior selection. The PRD AC "Defaults to 10% of Dwelling Coverage when a package is selected" requires this — without the normalization, picking 20% on Custom and then clicking HomeShield would carry the 20% across to HomeShield instead of resetting to the package default. handlePackageSelect does two things to the sanitized payload before dispatching:
PersonalPropertyandLossOfUseare deleted so Progressive'sUpdatePropertyCoveragePackageendpoint applies the new package's locked / default values (HomeShield = 50% PersonalProperty, HomeShield Plus = 70%, etc.).OtherStructuresis forced to'10'because Progressive's HAL does not seed a package default for this field. The new package's recomputed Dwelling then drives a fresh10% = $X coveragechip. The same'10'fallback is applied client-side atuseMemo-build-defaults time when (a) a package is selected and (b)OtherStructures.LastAnsweris empty, so the AC also holds on the very first render before the user touches a package card.- All persisted accordion state is reset (POD9-549) before the request goes out — a plan switch is the reset gesture.
handlePackageSelectclears all four confirmed snapshots (clearConfirmedAccordionforpropertyCoverages/liability/deductibles/addOns), callsresetCommittedAccordions(), and sets localaccordionStatusback toINITIAL_ACCORDION_STATUS. Without this, a previously-committed snapshot ingoosehead-progressive-home-coverages-uisessionStorage (e.g. user pickedOtherStructures: '20'/PersonalLiability: '500000'under HomeShield and clicked Next) would spread over the fresh HAL values + the'10'default at the next defaults-rebuild, the new plan would silently inherit the prior plan's edits, and the green "Complete" badges would persist. Snapshots are package-relative; a package change makes every one of them stale by definition.
Other coverage buckets (Liability, Deductibles, Add-ons) are NOT package-driven, so their current form values stay in the outgoing payload and Progressive recomputes each per the new package on the UpdatePropertyCoveragePackage round-trip. Their persisted snapshots and Complete badges are cleared alongside Property Coverages (POD9-549) so the carrier-returned defaults render and the customer re-reviews every section under the new plan. Selecting a different package is the explicit reset gesture — there is no separate "Reset to plan defaults" button.
Package-locked variant for Property Coverages (POD9-389)
Property-Coverages editable fields (OtherStructures, PersonalProperty, LossOfUse) render as a read-only "Included in {package} package" pill instead of a dropdown when HAL signals that the selected package has locked the value. Personal Property under HomeShield / HomeShield Plus is the canonical case; the heuristic is generic so future HAL locks on Loss of Use (or any future Property-Coverages field) auto-adopt the same variant with no code changes.
Single decision boundary lives in step-overrides/coverages/is-package-locked.ts:
export function isPackageLocked(question: ProgressiveQuestionConfig): boolean {
if (question.Disabled === true) return true;
if (question.ShouldDisableControl === true) return true;
if ((question.ValidValues?.length ?? 0) <= 1) return true;
return false;
}
Conservative-by-default: any HAL signal that the field cannot be edited (Disabled, ShouldDisableControl, or a collapsed single-ValidValue) flips the row into the locked variant. The visual layer lives in sections/package-locked-display.tsx and reads LastAnswer against ValidValues for the human-readable label, then resolves the package name via resolvePackageDisplayName(selectedPackage) (which strips the trailing " Package" suffix from HomeShield Package → HomeShield, etc.).
Color tokens come straight from Figma:
| Token | Value | Where |
|---|---|---|
| Locked-value text | #017429 | PackageLockedDisplay value text (Goosehead green) |
| Chip background | #D8F3F2 | PackageLockedDisplay "Included in {pkg} package" chip + PercentChip "10% = $X coverage" chip on Other Structures |
| Chip text | #006B67 | Same chips |
PercentChip was migrated from the prior #dcffe8 / var(--color-foreground) palette in the same change so both the locked-state chip and the computed "10% = $X" chip share identical teal styling.
Payload parity is preserved. Locked rows never render a <Controller>, but the underlying LastAnswer continues to round-trip through useForm({ shouldUnregister: false }) exactly as before — the locked field is still in form.getValues() and still survives sanitizeFormPayload. A regression test in coverages-step.spec.tsx (round-trips PersonalProperty via HAL LastAnswer when the field is package-locked under HS) pins this contract: the locked-state heuristic flips, the <select> disappears from the DOM, and the submitted payload still contains PersonalProperty: '50'.
The Other Structures description copy was also updated in this pass to end with "…but can be changed to fit your needs." per Figma; the override lives in label-overrides.ts so the HAL question data stays untouched.
Liability Coverages — PRD error copy. Personal Liability and Medical Payments dropdowns surface PRD-mandated error copy via COVERAGES_FIELD_ERROR_OVERRIDES in error-overrides.ts, threaded into buildStepSchema(allQuestions, HIDDEN_PROPERTIES, COVERAGES_FIELD_ERROR_OVERRIDES) in coverages-step.tsx. Generic "Please select an option" is replaced with "Please select a personal liability amount." / "Please select a medical payments amount.". Same pattern as the property-details step's field-error-overrides.ts. Personal Liability description body copy was also updated to match Figma verbatim (trailing "but can be changed to fit your needs.").
Per-accordion summary error banner
Each Coverages accordion renders the shared ValidationErrorBanner ("Please correct the following errors.") above its body when an explicit commit gesture finds a required-but-empty field. Mirrors the established pattern from About You / Property Details — same component (@goosehead-fastlane/ui-components ValidationErrorBanner), same copy, same arm/disarm contract. The banner is one bit of state per accordion, NEVER serialized to HAL, NEVER read from HAL.
Where the banner is armed:
| Trigger | Banner armed for |
|---|---|
In-card Next on Property / Liability / Deductibles fails validateAccordion(id) | That accordion only |
Page-level Save & Continue walk fails on the next idle predecessor (walkNextIdlePredecessor) | The predecessor accordion |
form.handleSubmit(_, handleFormSubmitError) runs with errors after every accordion has been walked at least once | Every accordion whose property set has a failing field (one bit per accordion) |
Where the banner is auto-disarmed:
Two independent paths cover the two ways field values change on the page:
- Per-field typing. A
form.watcheffect listens for changes to any field in an armed accordion's property set. On eachonChangeevent it re-runstriggerFieldValidationfor that accordion; if validation passes, the banner is disarmed. This makes typing a fix immediately clear the banner without forcing the user to re-click Next — same UX as About You / Property Details. - Wholesale
form.reset(defaults). Whendefaultschange (newstepConfigfrom a HAL refresh, package change response, or accordion snapshot commit), theprevDefaultsRefeffect runsform.reset(defaults)AND, in the same tick, re-validates every armed accordion. Re-validation cannot live on theform.watchpath here: react-hook-form fires the watch subscription withname === undefinedon a wholesale values update, but React's effect ordering means the previous render's banner subscription has been unsubscribed by the time this effect'sform.resetsynchronously fires the watch event. The watch event has no subscriber to receive it. Without re-validating directly in theprevDefaultsRefeffect, the inline field error correctly clears viaform.resetbut the banner stays visible — the production bug: empty a Liability dropdown, click Next so banner shows, click a different package card, inline error clears but banner stays. Fixed by the regression test "disarms the Liability banner after a package change repopulates the field via form.reset" incoverages-step.spec.tsx.
State shape (in coverages-step.tsx):
const [bannerVisibility, setBannerVisibility] = useState<Record<AccordionId, boolean>>(
INITIAL_BANNER_VISIBILITY, // all four false
);
HAL parity preserved. The banner state lives entirely in component state — it is never inserted into form.getValues(), sanitizeFormPayload, or any persisted UI store. A regression test (does not render any summary banner on initial mount) pins this contract along with the per-accordion arm / disarm tests below.
Test hooks: each accordion's banner has a stable data-testid of coverages-{id}-validation-banner (e.g. coverages-liability-validation-banner) for spec assertions.
Page-footer disclaimer (POD1-438 parity)
A static <p data-testid="coverages-disclaimer"> renders below the <form> but inside the <ContentCard>, mirroring the legacy _standard-flow Coverages page so DTC users see the same legal copy regardless of which renderer mounted. The exact string lives in the COVERAGES_DISCLAIMER constant at the top of coverages-step.tsx:
"The coverage descriptions provided are illustrative only; coverage may vary by insurer, state, and your policy terms. All examples assume coverage, and actual coverage will be dependent upon the specific terms of your policy. Coverage availability may vary. For a complete list of coverage offerings, please contact an agent."
Styling matches the standard flow verbatim (text-xs text-[var(--color-muted-foreground)] leading-relaxed).
HAL parity preserved. The disclaimer is a static string — it never enters form.getValues(), sanitizeFormPayload, or any persisted UI store. A regression test (renders the page-footer coverages disclaimer below the form) pins the copy verbatim, asserts it lives outside the <form> (so it never collides with submit / banner logic), and confirms it sits below the form in DOM order.
Tests
coverages-step.spec.tsx— four-accordion render, starts with Property open and the rest idle, idle-chevrondisabledassertion, "Coverages/Bill Plans" copy regression check, package-card selection state (HS / HSP / CU / empty), package-card click →onUpdateCoveragePackageshape,isUpdatingPackagelockout, accordion title prefixing by selected package, golden-payload parity againstsanitizeFormPayload, hidden HAL field round-trip (HomeComputerCoverage), package-locked PersonalProperty round-trips via HALLastAnswereven when the dropdown is replaced by the locked-display chip (POD9-389), client-only Roof Claims Settlement exclusion from payload, in-card Next records a commit in the store, full POD9-387 rehydration matrix (each commit count remounts to the auto-advanced-successor pattern), full walk + Save & Continue persists the addOns commit without collapsing it, reload-after-full-walk keepsaddOnsopen, half-filled-not-committed (no Next click → emptycommittedAccordionsafter remount),syncIdrescope wipes commits, Compare Packages placeholder modal open/close, the live "10% = $88,000 coverage" chip render, the per-accordion summary error banner ("Please correct the following errors.") arm-on-Next-failure / arm-on-Save-and-Continue-walk / arm-only-on-failing-accordion / disarm-when-fields-become-valid / no-banner-on-successful-Next contract, the page-footer coverages disclaimer (POD1-438 parity — string match + outside-the-form + below-the-form DOM ordering), and the plan-switch reset matrix (POD9-549): switching packages clears all four confirmed snapshots, resets everycommittedAccordionsflag, demotes the accordion UI back toINITIAL_ACCORDION_STATUS, falls a non-default Liability value back to the new plan's HAL default, and removes a previously-armed Liability banner.accordion-status.spec.ts— 4-id state machine primitives +deriveInitialAccordionStatustruth table across all sequential commit counts plus the non-contiguous degeneracy.field-groupings.spec.ts— bucket membership, Figma-ordered filters,HIDDEN_PROPERTIEScoverage forPackageType/PaymentOption/ItemizedScheduledPersonalProperty/HomeComputerCoverage, bucket disjointness,classifyPropertydefault =hidden.label-overrides.spec.ts— Figma copy for every bucket property, package-card override (HS / HSP / PL / CU),CUSTOM_PACKAGE_VALUE === 'CU', accordion title constants, Other Structures description ends with "but can be changed to fit your needs." (POD9-389).coverages-ui-store.spec.ts— persistence,ensureScoperescope,reset, accordion + roof-claims setters,markAccordionCommittedidempotency (reference equality on no-op writes),resetCommittedAccordions, sessionStoragepartializeround-trip of the committed map.title-prefix.spec.ts— package prefix resolution across HS / HSP / CU / unmapped codes / missing Display, plusresolvePackageDisplayNamereturningnullwhen no package is selected and stripping" Package"for HS / HSP / PL.is-package-locked.spec.ts— coversDisabled,ShouldDisableControl, single-ValidValue, emptyValidValues, and the standard editable case (POD9-389).sections/package-locked-display.spec.tsx— locked value text fromValidValues[LastAnswer].Displaywith fallbacks toValueand an em-dash; "Included in{package}package" chip text per HS / HSP / PL / CU; chip omitted whenSelectedPackage.LastAnsweris empty or the question is missing; color classes (text-[#017429],bg-[#D8F3F2],text-[#006B67]) asserted viatoHaveClass(POD9-389).sections/property-coverages-accordion.spec.tsx— base accordion behaviors plus the package-locked variant: PersonalProperty renderscoverage-locked-PersonalPropertyand nofield-PersonalProperty<select>under HS; chip reads "Included in HomeShield package" / "Included in HomeShield Plus package"; CU keeps the dropdown; single-ValidValuealso flips to locked; teal chip color tokens on both the locked-state chip and the computed Other Structures chip (POD9-389).package-selector.spec.tsx— renders HAL ValidValues + Custom card, selected-state for HAL values and forCU,onSelectargument shape, disabled state while package update is in-flight, compare-packages trigger wiring.addon-coverages-accordion.spec.tsx— open / complete / incomplete rendering, chevron toggles incompletestate (no terminal lockdown), bottom-checkbox cluster wiring.
Add-on Coverages — package-locked rows + included-package bullet list (POD9-392)
POD9-392 adds presentation-only PRD parity to the existing Add-on Coverages accordion. Three changes:
- Main 11 rows. HAL-locked rows render the same
PackageLockedDisplayvariant as Property Coverages (POD9-389). Detection reusesisPackageLockedverbatim, gated by a thinisAddonRowPackageLockedwrapper that opts theBlanketScheduledJewelrycurrency input out of the empty-ValidValuesarm of the heuristic only — explicit HALDisabled/ShouldDisableControlflags are still honored on currency rows so a future package that locks the blanket limit gets the read-only badge automatically. Lives atstep-overrides/coverages/is-addon-row-lockable.tswith theADDON_CURRENCY_PROPERTIESset hoisted intofield-groupings.tsas the single source of truth for "this is a free-form currency row". - Optional 7-checkbox cluster — split into bullet list + checkbox cluster. A locked + Y entry collapses into a green-bullet
IncludedPackageListunder "Here are the coverages included in the{package}package:" between the 11 main rows and the existing "Select any extra coverages …" heading. A locked + N entry is hidden (the field is not in the user's package and they cannot opt in). A not-locked entry stays in the existing checkbox cluster regardless ofLastAnswer. Detection helperisIncludedInPackageCheckboxreusesisPackageLockedplus the shared Yes-code resolution fromcoverages/yes-no-codes.tsso HAL variants like'1'/'0'keep working alongside the canonical'Y'/'N'codes. The sameresolveYesNoCodeshelper is now consumed byCoverageCheckboxCardtoo — eliminates the prior copy-paste between checkbox card and bullet detection. - PRD error copy.
error-overrides.tsaddsHiddenSeepageOrLeakage("Please select a hidden seepage or leakage limit.") explicitly per PRD AC andOrdinanceLaw("Please select an ordinance or law amount.") mirrored from the existing PersonalLiability / Wind&Hail / AOP convention (PRD did not pin Ordinance copy; see plan journal for the locked decision).
Field-grouping split is exposed via two new pure helpers in field-groupings.ts:
filterIncludedInPackageCheckboxes(questions)→filterAddonCheckboxQuestions(...).filter(isIncludedInPackageCheckbox)(bullets).filterOptionalAddonCheckboxes(questions)→filterAddonCheckboxQuestions(...).filter((q) => !isPackageLocked(q))(checkboxes).
filterAddonCheckboxQuestions itself is unchanged (still returns the full set including locked entries) so the existing test surface is preserved and the new helpers compose on top. Locked + N entries are implicitly hidden because neither helper includes them.
Payload parity preserved. Locked rows never render a <Controller> and the bullet list never mounts an input either, but the underlying LastAnswer continues to round-trip through useForm({ shouldUnregister: false }) exactly as before — both classes of locked field stay in form.getValues() and survive sanitizeFormPayload. Three regression tests in coverages-step.spec.tsx pin this contract:
round-trips PersonalProperty via HAL LastAnswer when the field is package-locked under HS(POD9-389, pre-existing).round-trips IncreaseJewelryWatchesFur via HAL LastAnswer when the field is package-locked under HS(POD9-392, new — main 11-row variant).round-trips locked-and-included optional cluster fields as bullets without losing the HAL LastAnswer(POD9-392, new — bullet variant).
Tests added in this pass:
error-overrides.spec.ts— HiddenSeepageOrLeakage / OrdinanceLaw PRD copy.is-addon-row-lockable.spec.ts— Disabled / ShouldDisableControl honored on currency rows; empty-ValidValuescarve-out only for currency rows.is-included-package-checkbox.spec.ts— locked + Y / locked + N / not locked / alternate Yes-code resolution / whitespace trim.yes-no-codes.spec.ts— shared Yes/No code resolver used byCoverageCheckboxCardandisIncludedInPackageCheckbox.field-groupings.spec.ts—ADDON_CURRENCY_PROPERTIESmembership; bullet/checkbox bucket disjointness; locked + N hidden from both buckets; alternateShouldDisableControllock signal.included-package-list.spec.tsx— null when empty; HS/HSP/CU heading copy; "selected" fallback when no package; label override / Label / Property fallback chain; intentional||vs??regression test for HALLabel: ''.addon-coverages-accordion.spec.tsx— locked variant for main 11 rows under each lock signal; bullet list integration with locked + Y / locked + N / not-locked entries; bullet heading per package; both headings absent when nothing qualifies for either bucket.
CoveragesHO defaults — server-seeded at duplication
Four fields on the Coverages page require a guaranteed initial value regardless of what Progressive's HAL returns first:
| Field | Seeded value | Why |
|---|---|---|
PersonalLiability | '500000' | Liability accordion PRD copy ("This is defaulted to $500,000 but can be changed to fit your needs.") expects $500K to be the visible selection on initial render. Progressive's HAL almost always seeds '500000' for new home quotes, but state-specific or atypical bootstraps can return a different value (e.g. '100000'). |
HiddenSeepageOrLeakage | '20000' | Add-on Coverages accordion expects the $20,000 limit to be visible on first landing per Figma. Progressive's HAL leaves this field unanswered on a fresh quote, so without seeding the dropdown would mount empty. |
LimitedFoundationAndSlabBuyback | 'Y' | Add-on Coverages optional checkbox cluster pre-checks Limited Foundation & Slab Buyback per Figma (opt-in by default). Progressive's HAL ships this yes/no field defaulted to 'N'; the user can still uncheck the box on the Coverages step. |
PersonalPropertyReplacementCost | 'Y' | Add-on Coverages optional checkbox cluster pre-checks Personal Property Replacement Cost per Figma (opt-in by default). Same opt-in-by-default rationale as Limited Foundation & Slab Buyback — Progressive's HAL ships this yes/no field as 'N' on a fresh quote and the user can still uncheck it. |
Rather than override per-render on the frontend (which reintroduces sessionStorage-vs-HAL drift), we seed all four values once at quote-duplication time and let HAL be authoritative from then on.
The implementation mirrors ProgressivePeoplePrefillStore:
ProgressiveCoveragesHoDefaultsStore(libs/apis/carriers/progressive/src/lib/application/services/progressive-coverages-ho-defaults.store.ts) — in-memory map keyed bysyncId. TTL = 1h, LRU = 500 entries. API:markPending(syncId, fieldMap),peek(syncId),clear(syncId). Generic over thefieldMapshape — adding a third field is a one-line change inmarkCoveragesHoDefaults.- At duplication time (
ProgressiveQuoteDuplicatorService.duplicateFromCrn): afterprefillProductsHoFromCrn+prefillPeopleFromCrn,markCoveragesHoDefaults(session.syncId)writes{ PersonalLiability: '500000', HiddenSeepageOrLeakage: '20000', LimitedFoundationAndSlabBuyback: 'Y', PersonalPropertyReplacementCost: 'Y' }. CoveragesHO is too far down the workflow to PUT directly here — Progressive's cursor is on ProductsHO — so we record the intent and apply later. - At first CoveragesHO landing (
ProgressiveStepService.applyPendingCoveragesHoDefaults): chained afterapplyPendingPeoplePrefillat every entry point that fetches HAL and returns astepConfig(getStepConfig,getStepConfigViaGoTo,submitStepRawadvance branch, and thegoToStateWithFallbackflow). When the current step is CoveragesHO and the store has a pending entry, the method PUTsrefreshStepRelevancywith the entire seed in a single round-trip, GETs fresh HAL, and clears the store. Any error is non-fatal — the original HAL is returned and the entry stays in the store for a future retry. - From then on HAL
LastAnsweris authoritative. User selections round-trip through Progressive's server, so a returning visit reads the user's actual saved value (e.g.'300000'forPersonalLiability,'5000'forHiddenSeepageOrLeakage,'N'if the user unchecked Limited Foundation & Slab Buyback or Personal Property Replacement Cost) without any frontend or sessionStorage gymnastics.
Why this beats FALLBACK_DEFAULTS
The frontend FALLBACK_DEFAULTS map only fires when HAL LastAnswer is empty. PersonalLiability requires overriding non-empty HAL values too (a state where Progressive seeds '100000'). HiddenSeepageOrLeakage happens to be empty on a fresh quote so a FALLBACK_DEFAULTS entry would technically work, but routing both through the same server-seeding mechanism keeps the contract uniform and avoids two divergent seeding paths. Server-seeding once at duplication time covers both empty and non-empty initial HAL values, and never has to second-guess whether a non-empty value came from "Progressive's default" or "the user's prior selection" — by the time the seed runs, no user has touched the step yet.
Trade-offs
- Gateway restart between duplication and first CoveragesHO landing drops the in-memory entry. The user would see Progressive's HAL default in that rare window. This matches the existing
ProgressivePeoplePrefillStoretrade-off; Redis-backing both stores is a follow-up if it proves observable in production. - Direct quote URLs that bypass
ProgressiveQuoteDuplicatorService(e.g. session-resume against a quote that was never duplicated through this code path) won't have the seed marked. Progressive's HAL is the source of truth in that case.
Tests
progressive-coverages-ho-defaults.store.spec.ts—markPending,peek,clear, multi-syncId isolation, overwrite semantics, empty-fieldMap and empty-syncId guards, TTL expiry, LRU eviction at capacity (500 entries).progressive-step-coverages-defaults.spec.ts— first CoveragesHO landing PUTs the seed viarefreshStepRelevancy, multi-field seed (PersonalLiability+HiddenSeepageOrLeakage+LimitedFoundationAndSlabBuyback+PersonalPropertyReplacementCost) is forwarded together in a single PUT, then GETs fresh HAL and clears the store; subsequent landings (store empty) are pure passthroughs; non-CoveragesHO steps never trigger the apply; PUT failures are non-fatal and preserve the store entry for retry.
Applied to: Checkout (FinalSaleHO)
Sixth production application of this playbook. Lives at apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/checkout/. Replaces the generic StepForm renderer for both FinalSaleHO (pre-bind) and PortfolioSoldQuote (post-bind) under one /carriers/progressive/home/checkout route.
What changed
- Sidebar stepper label override:
FinalSaleHO: 'Checkout'andPortfolioSoldQuote: 'Checkout'inshell/step-label-overrides.ts. - Slug overrides in
shell/workflow.tskeepFinalSaleHOandPortfolioSoldQuoteon the same/checkoutURL so the post-bind state does not change the address bar. - Two-component dispatch under one orchestrator:
CheckoutContent(pre-bind) when!isSold || !policyNumber,PostBindContent(Figma 2210-26074) whenisSold && policyNumberis true OR whenworkflowNode === 'PortfolioSoldQuote'. - The page-level
Save & Continueis hidden viahideContinue={isCheckoutStep}inflow-shell.tsx. Submission is driven exclusively by the in-cardSubmit Paymentbutton. ThePrevious: Mortgagebutton stays. - Right-rail Payment Summary card (Figma 2210-25520) renders via
QuoteFlowLayout'scontextRailslot when the user is on Checkout pre-bind. The address line is derived fromstepConfig.formValues(PropertyAddress/MailingAddressfamily) — the Payment Summary remains anchored to the live HAL state. - The Mortgagee path is the only path supported in the first pass — the DTC config-driven flow already knocks out users without a mortgage at the About You step (troubleshooting-config-driven.md §"NamedInsured Step Override"). Card / EFT / Check fields are HIDDEN at the UI layer and never reach
sanitizeFormPayload.
Backend parity (FinalSaleAuthFlags + sell-quote routing)
Captured live from FAO QA against PROGRESSIVE_POC_PLAYWRIGHT_QUOTE_NUMBER — see fao-checkout-bind-capture.md. FAO uses PUT FinalSalePropertySellQuote?validate=FinalSalePropertySellQuote for the bind, NOT POST NextWorkflowState. Two backend changes were required:
FinalSaleAuthFlagsVO (libs/apis/carriers/progressive/src/lib/domain/value-objects/final-sale-auth-flags.ts). Mirrored on theapplyIfRequired(workflowNode, formData)shape fromProductsHoVerificationFlags. OnFinalSaleHOit forces:ConfirmPrimaryEmailAddress=PrimaryEmailAddressso HAL's email-confirm validator passes.AdditionalMortgagePaymentIndicatordefaults to'N'when empty.
- Sell-link routing in
submitStepRaw(libs/apis/carriers/progressive/src/lib/application/services/progressive-step.service.ts). TherunFinalSaleSellQuote(...)helper short-circuits thesaveWithRerateRetry+advanceWithRerateRetrypath. It callsexecuteSellPropertyQuote(session, currentState, payload)which PUTs theFinalSalePropertySellQuotelink with the nested payload (defensive fallback toNextWorkflowStatewhen the link is absent). The response is the post-bind HAL withIsSoldQuote=true+PolicyNumber.extractPremiumSummaryalready lifts those fields ontostepConfig.premiumSummary.
Hidden-but-submitted fields
HAL exposes these properties on FinalSaleHO but the Figma Checkout never surfaces them. useForm({ shouldUnregister: false }) round-trips each through sanitizeFormPayload via HAL LastAnswer. No new FALLBACK_DEFAULTS entries needed.
PropertyAgentOfRecord,PhoneType,PhoneNumber,PrimaryEmailAddress,ConfirmPrimaryEmailAddressAdditionalMortgagePaymentIndicator(defaults to'N'viaFinalSaleAuthFlags)PropertyPaymentMethod(forced to'Mortgagee'at submit time, defense-in-depth between FE and BE)CreditCardName,CreditCardNumber,CreditCardExpirationDate,CreditCardholderZip(always empty for Mortgage path)EsignAuthorization(forced to'Y'— the disclosure paragraph copy IS the consent UX)RemarkText,PolicyInitialPaymentAmount,AmountPaidToday,DownPaymentAmount
All enumerated in field-groupings.ts → CHECKOUT_HIDDEN_PROPERTIES. CHECKOUT_SCHEMA_EXCLUDED_PROPERTIES is the same set passed as the second arg to buildStepSchema(...) — same trap as named-insured §11c.
Client-only UI store (checkout-ui-store.ts)
sessionStorage, scoped by syncId:
hasAttemptedSubmit: boolean— flips on Submit Payment click. Used by the orchestrator to surface validation errors after the first failed attempt without surprising the user on initial render.
The key never appears in the submitted payload (asserted by the parity test).
Right-side Payment Summary
Rendered via QuoteFlowLayout.contextRail from flow-shell.tsx, gated on isCheckoutStep && !isPostBindCheckout. Reads from stepConfig.premiumSummary directly:
companyNamefalls back to the static "Progressive Home" string per Figma.totalPremiumformatted as USD currency.policyStart/policyEndformatted asMM/DD/YYYY - MM/DD/YYYY(handlesYYYYMMDD, ISO, andMM/DD/YYYYHAL formats).- Property address derived from the form values via
resolveCheckoutPropertyAddress.
Submit Payment payload
The orchestrator's submit handler:
const merged = { ...stepConfig.formValues, ...data };
const sanitizedPayload = sanitizeFormPayload(merged, allQuestions);
const finalPayload = {
...sanitizedPayload,
PropertyPaymentMethod: 'Mortgagee',
EsignAuthorization: 'Y',
AdditionalMortgagePaymentIndicator: <existing or 'N'>,
ConfirmPrimaryEmailAddress: <PrimaryEmailAddress>,
};
The same flags are also enforced server-side by FinalSaleAuthFlags.applyIfRequired in submitStepRaw so a malformed frontend cannot ever bypass them.
Tests
checkout-step.spec.tsx— Pre-bind UI render (title, subtitle, Mortgagee + Full Payment, single + two-mortgagee variants, disclosure, in-card Submit button), processing modal toggle, golden-payload parity againstsanitizeFormPayload+ auth-flags overlay, hidden-but-submitted (each card / phone / email / esign property absent from DOM, present in payload), Post-bind dispatch whenisSold + policyNumber, Post-bind dispatch whenworkflowNode === 'PortfolioSoldQuote', Print Home Docs button visibility, processing modal hidden post-bind, UI store persistence +syncIdrescope.field-groupings.spec.ts—CHECKOUT_HIDDEN_PROPERTIESmembership,CHECKOUT_SCHEMA_EXCLUDED_PROPERTIEScovers all hidden,CHECKOUT_PROPERTY_PAYMENT_METHOD_MORTGAGEEvalue,collectAllQuestions+findQuestionByPropertylookups.label-overrides.spec.ts— Figma copy andresolvePaymentFrequencyLabel(Mortgage Billed → "Full Payment"; non-mortgage echoed verbatim).payment-summary-card.spec.tsx— carrier label, address rendering, currency formatting, em-dash fallbacks, MM/DD/YYYY formatting fromYYYYMMDDHAL inputs.post-bind-content.spec.tsx— policy-number block presence, Print Home Docs button visibility perprintDocumentsUri.checkout-ui-store.spec.ts—ensureScopesemantics (null / same / differentsyncId),reset,setHasAttemptedSubmitindependence.- Backend:
final-sale-auth-flags.spec.ts(VO unit tests across mirroring + defaulting paths) +progressive-step-final-sale.spec.tscoveringisFinalSaleStep, sell-link path, NextWorkflowState fallback.
Helpful Hints Drawer (Property Details + Additional Details + Coverages)
Single side-panel "Helpful Hints" rail consolidates THREE previously-divergent UX patterns into one Safeco-style drawer:
| Replaced | Replacement |
|---|---|
POD9-395 v1 — PropertyDetailsFieldInfoHint (Radix Tooltip on desktop / Popover on mobile) | ProgressiveHomeHelpfulHintsIconTrigger → ResponsiveHelpfulHints rail |
POD9-319 — CoveragesInformationSheet (single page-level "Coverage information" button + Radix Sheet slide-over) | Per-row info icons in each of the 4 Coverages accordions → same shared rail |
Dead code — InfoTooltipIcon static SVG inside coverages/sections/coverage-row.tsx | labelAccessory slot on CoverageRow; only rows in the helpful-hints PRD scope render an icon |
Architecture
Mirrors Safeco Auto's helpful-hints pattern 1:1 — same provider, same context, same focus-restoration handshake — but typed against a Progressive Home-specific key union so cross-carrier keys cannot leak.
| File | Responsibility |
|---|---|
shell/helpful-hints.tsx | ProgressiveHomeHelpfulHintsProvider, useProgressiveHomeHelpfulHints, useRegisterDefaultProgressiveHomeHelpfulHintsKey |
shell/helpful-hints-content.tsx | PROGRESSIVE_HOME_HELPFUL_HINT_KEYS (string union), the verbatim PRD copy map, and the per-section createXxxHelpfulHints(defaultOpenId) factories. Bold-led term/definition pairs render as ReactNode via the local definitionList() helper; Coverages prose renders via paragraphStack(). |
shell/helpful-hints-icon-trigger.tsx | Reusable 24 px green IconInfo button. Calls toggleHelpfulHints(key, currentTarget) so the rail can restore focus to the triggering element on dismiss. |
shell/flow-shell.tsx | Wraps ConfigDrivenFlowInner in the provider, derives the rail from the active content, and auto-closes the rail on stepConfig.workflowNode change. |
Canonical implementation
Safeco Auto's apps/fastlane-portal/src/app/pages/carriers/safeco/auto/helpful-hints*.{ts,tsx} files are the cross-carrier source of truth for the pattern. Progressive Home mirrors them verbatim. Do NOT promote the primitives to a shared libs/... package until at least a third carrier needs the pattern; per-carrier scope keeps the typed key union meaningful.
Panel composition (8 panels, 22 info icons)
| Panel | Rows | Notes |
|---|---|---|
property-details.eligibility | Electrical System, Plumbing System | Figma-locked PRD copy |
property-details.exterior | Construction Type, Foundation/Substructure, Garage/Carport | Carryover paragraph copy (string) — renders as a single <p> per the ResponsiveHelpfulHints string branch |
property-details.interior | Bathroom Finishes, Kitchen Finishes, Cooling/Air Systems, Heating Systems | PRD bullets |
additional-details.discounts | Burglar Alarm/Security System, Fire Protection, Water Leak Detection | PRD intro + bullets + outro paragraphs. Water Leak Detection category names use en-dashes (–, U+2013), NOT hyphens |
coverages.property | Other Structures, Personal Property, Loss of Use | Replaces POD9-319 sheet |
coverages.liability | Personal Liability, Medical Payments | Medical Payments keeps the literal "Example:" label per PRD |
coverages.deductibles | Wind & Hail Out of Pocket, All Other Perils | PRD-pinned worked examples |
coverages.addons | Hidden Seepage or Leakage, Ordinance or Law, Water Backup Coverage | LossAssessment is intentionally omitted — PRD body empty (follow-up ticket); regression test pins the skip |
contextRail precedence in flow-shell.tsx
isCheckoutStep && !isPostBindCheckout ? <PaymentSummaryCard … /> :
isHelpfulHintsOpen && activeContent ? <ResponsiveHelpfulHints … /> :
undefined
Checkout's PaymentSummaryCard MUST keep priority over the rail on FinalSaleHO / Portfolio workflow nodes. The rail will not preempt it.
Rail width & header gutter alignment
The desktop rail is a fixed 390px wide (w-[390px] min-w-[390px] in shell/responsive-helpful-hints.tsx). Its right edge is meant to sit directly under the page header's hamburger menu icon. That alignment is NOT produced by the rail itself — it falls out of the Progressive Home content container sharing the PortalHeader's horizontal geometry.
flow-shell.tsx passes className="p-0 lg:py-6 lg:px-[60px] max-w-[1512px]" to QuoteFlowLayout (the shared layout otherwise defaults to max-w-[1400px] with whatever padding the carrier passes). This makes the content's inner box identical to the header's (max-w-[1512px] + md:px-[60px], both mx-auto-centered), so at lg+ the sidebar's left edge lands under the Goosehead logo and the rail's right edge lands under the hamburger at every desktop width. The header and the content used to differ (1512/60px vs 1400/24px), so their right edges never lined up and the offset even flipped sign across viewport widths — no single static nudge could fix it, which is why the fix unifies the container geometry instead. The shared QuoteFlowLayout is untouched (the override is scoped to Progressive Home via className only), so other carriers keep the 1400px box.
Coupled magic numbers that must move together if the rail width changes:
resolve-footer-class-name.ts→lg:pr-[414px](= 390 rail + 24pxgap-6) keeps the page-level Save & Continue button aligned with the step column's right edge instead of letting it land under the rail. (Checkout hides the continue button viahideContinue, so the narrowerPaymentSummaryCardnever needs this offset to match its width.)- The divider-line offsets in
responsive-helpful-hints.tsx(before:-top-6,before:-bottom-[120px],min-h-[calc(100vh-3rem)]) depend on the vertical padding being 24px (lg:py-6) — unchanged by the gutter switch, which only touched the horizontal padding and max-width. - The desktop close (X) button carries
-mr-6so it sits flush with the panel's right edge instead ofpr-6(24px) inside it. The panel right edge is aligned with the header's inner right edge, and the hamburger button is flush to that edge too, so the X icon ends up vertically aligned with the hamburger icon (both ~4px inset in a square icon button). Keep-mr-6equal to the panel'spr-6—responsive-helpful-hints.spec.tsxpins the-mr-6class.
resolve-footer-class-name.spec.ts and responsive-helpful-hints.spec.tsx pin both the 390px rail width and the 414px footer offset so a future change forces a matching update.
Trap: tooltip vs drawer
Before this consolidation, Property Details surfaced helpful-hints copy via a Radix tooltip (desktop) / Radix popover (mobile); Coverages used a single page-level button that opened a Radix Sheet slide-over with a totally different layout; Additional Details Discounts had no helpful-hints UI at all. Three implementations, three layouts, three sets of test surface area.
The current rule is: every helpful-hint goes through ProgressiveHomeHelpfulHintsIconTrigger. If you find yourself reaching for Tooltip, Popover, or Sheet to surface educational copy on Property Details / Additional Details / Coverages, stop — wire the new content through shell/helpful-hints-content.tsx instead.
PRD-pinned punctuation
The PRD copy contains three deliberate punctuation choices that look like typos but are not. shell/helpful-hints-content.spec.ts pins each one with a regression assertion so a future normalization PR cannot strip them.
- Fire Protection outro paragraph ends with a period.
- Hidden Seepage Example paragraph ends with a period.
- Medical Payments second paragraph carries the literal "Example:" label.
Related references
- Full architecture:
./config-driven-poc.md - Debugging sequences:
./troubleshooting-config-driven.md - DA → Fastlane bootstrap:
./troubleshooting-progressive-config-da-to-fastlane.md - FAO Checkout / Bind capture:
./fao-checkout-bind-capture.md - Reference implementation (two-accordion):
apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/named-insured/ - Reference implementation (four-accordion + HAL gate + silent backend RCE adopt):
apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/property-details/ - Reference implementation (three-accordion + GAQ conditional):
apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/household-members/ - Reference implementation (three-accordion + hidden-but-submitted round-trip):
apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/additional-details/ - Reference implementation (four-accordion + package selector + client-only pill):
apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/coverages/ - Reference implementation (sell-quote bind + post-bind dispatch):
apps/fastlane-portal/src/app/pages/carriers/progressive/home/config-driven/step-overrides/checkout/ - Reference implementation (helpful-hints rail; Safeco Auto canonical pattern):
apps/fastlane-portal/src/app/pages/carriers/safeco/auto/helpful-hints*.{ts,tsx}