Skip to content

Product comparison view

The /compare route is the side-by-side comparison of the Product Comparison capability (#5, delivered by #79): 2–4 systems rendered attribute-by-attribute over the single-source product model, with the distinguishing rows highlighted so the deciding differences stand out instead of drowning in identical rows.

The ?systems= contract

/compare?systems=<id,id[,id[,id]]> is the shared hand-off contract: the guided flow's results view (#82) targets it with the shortlist, and any surface can link a preselected comparison with compareHref(systemIds) from $lib/comparison.

Parsing/validation is pure and unit-tested (src/lib/comparison.ts):

  • ids are trimmed, de-duplicated (first occurrence wins, order preserved);
  • unknown ids are surfaced in a notice, never silently dropped;
  • more than 4 valid ids is refused with a notice — a comparison is never silently truncated;
  • fewer than 2 valid ids (or no param) falls back to the standalone picker.

Behavior

PieceBehavior
Standalone pickerChoose 2–4 active systems (checkboxes disable at 4) — comparison does not depend on the guided flow.
MatrixAttributes as rows (catalog display order), systems as columns with image/tagline header cards. Cells are formatted server-side (formatValue is server-only); missing cells render .
Header media (#245)Each system column shows the system's primary media image from the media library — the primary attachment, or the first image by position when the primary is a video; systems with no image render no <img> at all. Served via the responsive <picture> component ($lib/media/ResponsiveImage.svelte): AVIF/WebP/JPEG sized renditions from the transform endpoint (originals never reach buyers), eager-loaded since the header sits above the fold. Alt text comes from the asset (alt="" when marked decorative). The legacy systems.image_url stub no longer renders anywhere.
Attribute media (#411)Each attribute row header shows the attribute's primary attached media as a small icon beside the name — batched via primaryEntityImages('attribute', …), rendered by EntityHeroMedia; an attribute with no media shows just its name.
Grounded differentiation blurb (#1544)Each system column header carries one compact cited corpus passage answering "so what if they differ?" — system-level prose, never per-cell noise. Loaded by $lib/server/compare-evidence (loadCompareEvidence): ≤4 best-effort lexical-only retrieveBySystem calls (marketing-leaning with an any-type fallback, hybrid pinned off — zero AI spend, load-time only), shaped to CitedPassage with the pdp deep-link seam. Columns de-dupe against each other (siblings ranking the same family brochure first each take a distinct passage) under a wrong-system guard (#1573): a passage naming a different catalog system without naming the column's own never renders — a duplicate family line beats a wrong-system claim, and a column with no acceptable passage gets no blurb. Rendered at the WhyThisFits compact scale — tight quote (compactEvidence), source link, content-type badge, capture version. A system with no evidence renders its column exactly as before; a retrieval failure degrades to no blurb, never a broken page.
Allowed-values legend (#411 / #255)An enum attribute row offers a "What these values mean" disclosure (native <details>) that teaches each allowed value — its #255 education copy and its attached enum_value media (keyed by the composite attributeId:value via enumValueEntityId). Only values with education copy or media appear, so a bare catalog adds no empty legend. This is the first public surface to teach allowed-values (previously admin-only).
Difference highlightingRows where systems disagree are tinted and edge-marked, driven by the data layer's differs flag (#78 — see data-access.md); the UI never re-derives equality.
Only show differencesA toggle filters to differing rows (with a count, e.g. "23 of 58"); an all-identical comparison explains itself instead of rendering an empty table.
Deep-linksEach system header carries three per-system affordances via the $lib/deeplink seam (#414): Product detail (the captured PDP path — systems.pdp_path; omitted when none was captured, 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 (prod https://www.nanawall.com), passed from the load as deeplinkBase. That module is the single seam to update.
Retired dataRetired systems and retired attributes never appear (active-only defaults of listSystems/getComparison are preserved).

Server/client split

+page.server.ts reads exclusively through the data-access layer and emits a plain view-model (CompareRowView: attribute, display-string cells, differs); the page owns only view state (picker selection, the differences toggle). TypedValue never crosses to the client.