Appearance
Guided selection stepper
The /select route is the Guided Selection Flow (#3) end to end: a stepped questionnaire (#31) that converges on a typed answer set — the SelectionContext the recommendation matching engine consumes — and a results view (#62, #82) that renders the right-sized recommendation with its rationale and hands off onward: one click into the comparison matrix pre-filled with the shortlist, and a configurator/resources deep-link per shortlisted system. Ask → recommend → act, in one surface.
The flow is data-driven, never hard-coded: steps are exactly the active factors returned by listFactors (ordered by display_order, retired excluded), so a factor added, reordered, or retired in the Admin UX surfaces in the flow without a code change.
Architecture
Two layers, mirroring the pure-shaper convention used across the codebase:
| Layer | File | Responsibility |
|---|---|---|
| Pure state module | src/lib/selection.ts | Progress, navigation boundaries, answer bookkeeping, answers → SelectionContext, tolerant persistence parse/serialize. Unit-tested without a browser (selection.spec.ts). |
| Route | src/routes/select/ | +page.server.ts loads factors through the data-access layer; +page.svelte renders one factor per step and owns the sessionStorage IO. |
SelectionContext ({ optionIds: string[] }) is re-declared in $lib/selection rather than imported from $lib/server/recommend — SvelteKit forbids client imports of server-only modules. A compile-time assertion in selection.spec.ts fails npm run check if the two shapes ever drift.
Behavior
- One factor per step, with the factor's name, description, and applicability note; options render as a radio group. Re-selecting the chosen option clears it.
- Education placement (#1479, superseding the #363 inference) — every entity's Education unit (the factor's
educationtrade-off narrative from #80 / each option's "what this means" text, plus its attached #411 education visual) renders where the admin placed it. Description is always inline framing and never moves.- Inline (the default;
NULLin thedisplay_modecolumn) — the factor's education paragraphs render as a block after the description and its visual renders as the step's full-bleed backdrop; an option's text renders beneath its label. No "?" renders. - In "?" modal — nothing renders inline; a "?" (factor: beside the step title; option: a sibling of its toggle, never nested) opens a native
<dialog>(showModal():::backdrop, focus trap, Escape-to-close, focus returned on dismiss) containing whatever exists — text and/or visual. An image-only modal is just a modal-placed unit with empty text; there is no separate media knob. - No empty "?" — the affordance exists iff modal-placed content exists (
showEducationAffordanceinentity-disclosure.ts, pure and unit-tested). There is no content-weight inference: the formerinferDisclosureMode/RICH_TEXT_THRESHOLDlayer is retired. The "inline ↔ NULL" convention is decoded/encoded in one place (toEducationPlacement/fromEducationPlacement); set placement on the factors admin. - Media inside the modal renders through the shared
EntityHeroMediadispatcher (plain image viaMediaFrame+ResponsiveImage; a Wistia still that loads the player on click); each option also keeps its thumbnail beside the label as a selection aid. Content comes fromlistFactors, which batches oneprimaryEntityImageslookup per entity type so it scales with the whole factor set loaded upfront; a failed media read degrades to text-only rather than erroring. - Education-only (#1706, retiring the #1543 corpus-fed modal) — the modal holds exactly the Education unit: the dedicated education visual first, then the teaching text. No visible heading (the dialog is named for assistive tech via
aria-label) and no corpus citations — the #1543loadEducationPassagespre-bake and its plumbing were removed; corpus evidence still renders where it belongs, in the results page's "Why this fits" and NanaSage answers.
- Inline (the default;
- Progress + boundaries — "Step N of M"; no Previous on the first step; the last step's forward action is See results (advance-to-results), never Next.
- Answers persist in
sessionStorage(nanaselect:guided-answers): reloads and back/forward navigation keep previously entered answers. Stored answers are parsed tolerantly — garbage JSON, unknown factors, and vanished options are dropped silently so a stale session never breaks the flow. - Cross-visit resume (#115) — when a returning visitor's anonymous server session (session store, #114) holds usable state and the tab has no in-flight answers of its own, a non-modal Welcome back card above the stepper offers to pick up where they left off. In-progress sessions offer Resume (restores answers, lands on the first unseen step) / Start fresh; completed sessions offer See your results (reopens the results surface — the recommendation refetches live) / Start over. Precedence is deterministic and pure (
resumeOffer): live client state always wins and suppresses the offer, so same-tab reloads stay silent; an expired, purged, or all-stale server session degrades to ordinary client-only behavior — never an error and never a prompt. Declining is tab-local: the server record stays until new activity overwrites it, so a later return visit offers again. - Answer semantics —
factorId → optionId, withnullfor a step the user moved past without answering (visited-but-skipped) and absent for an unseen step.stepStatusreads this as the user-facing lifecycle: answered / skipped / unseen. - Skip & fast-path (#81) — every step has an explicit Skip this question action (records no-constraint and advances); a clickable status dot-strip marks each step answered (blue) / skipped (amber) / unseen (hollow) and jumps straight to it; and an All questions overview lists the whole questionnaire with statuses and chosen labels, so a returning pro answers only what matters, in any order. A skipped factor contributes nothing to the answer set — distinguishable from an answered-neutral value, which is a real optionId. The summary states plainly when a recommendation will be computed from N of M factors.
- Completion lands on an answer-summary surface showing every factor and the chosen (or skipped) option, plus the answered count — and, when at least one answer was given, the recommendation itself (#62): the preferred system with its admin-authored rationale, an over-spec nudge when a cheaper tier would suffice, and cost-effective alternatives, all rendered from the recommendation contract with no engine logic in the UI. Returning into the flow and changing answers re-fetches, so the result always reflects the current answer set; a no-match session shows the contract's explained empty result, not a dead end.
- Results tabs (#836, #1423, #1481) — the results view is a URL-synced tablist (
?view=, sharedTabscomponent): Your recommendation (the payoff, default) → Why this system → Your answers (with the quiet completeness meter) → Projects like yours (#983, only when linkable projects exist). The payoff tab keeps the verdict payoff-first: the card shows a condensed "Why this fits" — the top 3 rationale bullets, kept whole (thecondensedWhyrule never cuts mid-sentence, and citations never render condensed) — with a "More on why this fits →" link to the merged Why this system tab. That tab (#1481, merging #1423's separate why/journey tabs) leads with the engine explainer's staged flow (#561) — answers → rules → systems → right-sized pick, from the engine's own trace — and closes with the corpus-grounded evidence (#455) in compact form: at most two citation quotes, tightly truncated (compactEvidence), each keeping its source link, content-type badge, and capture-version label, plus the real-installation line and the product-resources link. The standalone rationale-bullet wall is retired — the explainer's stages carry that reasoning. The tab exists only when its content does (no empty panels); stale?view=whyand?view=journeydeep-links resolve to it (RESULTS_VIEW_ALIASESthrough the Tabs alias fallback), never a blank panel. A broadsheet small-screen session also gets a Photos tab after the payoff. - Entity media (#245) — the lead recommendation card hosts the system's full media-library gallery via the shared
MediaGallerycomponent, respecting the admin's per-gallery grid/slideshow display-mode; each cost-effective alternative row shows the system's primary image as a thumbnail. All images are sized renditions through the responsive<picture>component and lazy-load (the results surface sits below the fold); a system with no media renders no image at all — the cards read fine without one, no placeholder. Alt text comes from the asset (alt=""when decorative). - Hand-offs (#82) — the results view is a pathway, not a terminus. Compare these systems opens
/compare?systems=<ids>(the #79 contract, built bycompareHref) pre-filled with the shortlist — the recommendation plus its alternatives, in rendered order (shown when the shortlist has at least two systems). Every shortlisted system also carries three per-system deep-links through the$lib/deeplinkseam (#414, fulfilling #7): Product detail (the captured PDP path — omitted when none exists, never a broken link), Configure & Price ({base}/resources/{id}#configure) and Resources ({base}/resources/{id}#resources). The resources slug is the system id;baseisDRUPAL_BASE_URL, passed to the flow asdeeplinkBase. Cost is calculated in the configurator, never in NanaSelect. - Resource-aware warm hand-off (#456) — the recommendation endpoint assembles a
handoffpayload ($lib/server/handoff.ts,buildWarmHandoff) so the configurator / contact-sales entry lands pre-loaded rather than cold. It carries the recommended product's deep-links, its key resources (the ingested resource documents surfaced for the system, plus the canonical resources-page link), the matched real projects (#345), and a dealer slot. The results view renders the pre-loaded resource documents; the lead sync carries the hand-off deep-links (see therecommendation_resources/recommendation_configuresession-field keys in eloqua-integration.md). Every slot degrades independently — no PDP path, no ingested resources, no matched projects, or a null dealer each fall back to an empty/null slot; the hand-off is never blocked. The dealer/rep slot (#704) routes to the nanawall.com repfinder — the How to Buy page's self-geolocating "local representatives" block (repFinderLink()indeeplink.ts) — rather than an in-app dealer data source; the results card renders it as "Find a local representative", and nanawall.com does the near-you resolution.
Track routing (/select/<slug>)
Tracks (#144, #152) are named, buyer-facing variants of this flow, authored in the admin area (managing-tracks). The stepper itself is shared: SelectionFlow.svelte renders both routes, and a track only changes what it is fed.
| Route | Behavior |
|---|---|
/select | The default flow — all active factors in catalog order. The "default track" is this sentinel route, not a DB row; pre-track links behave exactly as before. |
/select/<slug> | A published track: only the track's factors, in the track's order, with the track's pre-seeded answers merged into the engine context before the first step. |
| Retired slug | Friendly "no longer available" notice with a link to /select. Never a 500. |
| Unknown slug | Friendly "no flow here" notice with a link to /select. A draft track renders this same notice — an unpublished URL must not confirm the track exists. |
Mechanics (pure module: $lib/track-flow.ts, spec-proven):
- Pre-seeds are engine-invisible. A track stores
factorId → option value; the route resolves those to option ids and merges them intoSelectionContext, so recommendations behave exactly as if the buyer had answered by hand (parity proven against the real matcher intrack-flow.spec.ts). The engine (#4) is untouched. - Session-start snapshot. The first visit in a tab session pins the track's config (factor order + pre-seeded ids) to
sessionStorage; an admin edit mid-flight never reshapes an in-progress flow. Factor content stays live; ids that stop resolving drop harmlessly. - Per-track persistence. Answers persist under
nanaselect:guided-answers:track:<slug>, so the default flow and parallel tracks never bleed answers into each other. The cross-visit resume offer (#115) parses the server session against the scoped factor list — answers from other flows vanish rather than resurface.
Results experience (#1004 pass, synthesized by #1010)
The results view is the screen that earns the buyer's belief in the recommendation — its treatment is deliberate and verifiable:
- Decisive verdict. The recommended system opens with the eyebrow + serif name, then one confident line — the top rule rationale promoted out of the bullet list (
verdictLinein$lib/recommended-card); remaining rationales render as supporting bullets. No tentative or apologetic copy — a grep-verifiable guard (results-copy.guard.spec.ts) bans hedging/process meta-framing from the results surfaces (#1007/#1010). - Imagery leads. The card opens with the large slideshow hero (#1006), full-bleed under the Broadsheet layout (#1005); a system with no media collapses the slot — no placeholder, no gap.
- One dominant next step. Exactly one primary action — See cost in the configurator — sits directly under the verdict, above the fold; Resources, product detail, compare, alternatives, and print/share are visibly subordinate.
- Evidence with confidence. Cited passages render as reading content with quiet per-citation attribution (title + link + capture version); links are liveness-gated — nothing on the results view can 404 (no-dead-links guarantee).
- Real proof. "Projects like yours" is its own tab (#983): every row links to a real nanawall.com project page; backfilled
relatedrows make no system claim. - Stable under navigation. Results tabs are URL-synced; browser back steps one question (navigation policy, #1016); a reload re-renders the same screen (#1026); home resume deep-links land on results or the first unanswered question (#1027).
Edge states
| State | Behavior |
|---|---|
| No active factors | Friendly empty state pointing at the admin area — no crash, no blank page. |
| Single factor | That step is both first and last: Previous disabled, forward action is See results. |
| Factor with no options | The step renders a "skip ahead" note instead of an empty radio group. |
| Fully-skipped session | Valid empty SelectionContext ({ optionIds: [] }) — "no constraints", plus a summary nudge to answer at least one question. The engine degrades, it never errors. |
| Stale stored session | Entries for factors/options that no longer exist are dropped on parse. |
Consumers
- The recommendation engine (#4) consumes
toSelectionContext(factors, answers)via the recommendation contract (#62). - The comparison matrix receives the shortlist via
/compare?systems=(#79); the configurator pathway (#7/#414) receives each shortlisted system via the$lib/deeplinkseam (product-detail / configure / resources builders).