Skip to content

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:

LayerFileResponsibility
Pure state modulesrc/lib/selection.tsProgress, navigation boundaries, answer bookkeeping, answers → SelectionContext, tolerant persistence parse/serialize. Unit-tested without a browser (selection.spec.ts).
Routesrc/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 education trade-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; NULL in the display_mode column) — 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 (showEducationAffordance in entity-disclosure.ts, pure and unit-tested). There is no content-weight inference: the former inferDisclosureMode / RICH_TEXT_THRESHOLD layer 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 EntityHeroMedia dispatcher (plain image via MediaFrame + 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 from listFactors, which batches one primaryEntityImages lookup 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 #1543 loadEducationPassages pre-bake and its plumbing were removed; corpus evidence still renders where it belongs, in the results page's "Why this fits" and NanaSage answers.
  • 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 semanticsfactorId → optionId, with null for a step the user moved past without answering (visited-but-skipped) and absent for an unseen step. stepStatus reads 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=, shared Tabs component): Your recommendation (the payoff, default) → Why this systemYour 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 (the condensedWhy rule 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=why and ?view=journey deep-links resolve to it (RESULTS_VIEW_ALIASES through 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 MediaGallery component, 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 by compareHref) 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/deeplink seam (#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; base is DRUPAL_BASE_URL, passed to the flow as deeplinkBase. Cost is calculated in the configurator, never in NanaSelect.
  • Resource-aware warm hand-off (#456) — the recommendation endpoint assembles a handoff payload ($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 the recommendation_resources / recommendation_configure session-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() in deeplink.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.

RouteBehavior
/selectThe 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 slugFriendly "no longer available" notice with a link to /select. Never a 500.
Unknown slugFriendly "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 into SelectionContext, so recommendations behave exactly as if the buyer had answered by hand (parity proven against the real matcher in track-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 (verdictLine in $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 related rows 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

StateBehavior
No active factorsFriendly empty state pointing at the admin area — no crash, no blank page.
Single factorThat step is both first and last: Previous disabled, forward action is See results.
Factor with no optionsThe step renders a "skip ahead" note instead of an empty radio group.
Fully-skipped sessionValid empty SelectionContext ({ optionIds: [] }) — "no constraints", plus a summary nudge to answer at least one question. The engine degrades, it never errors.
Stale stored sessionEntries 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/deeplink seam (product-detail / configure / resources builders).