Appearance
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
| Piece | Behavior |
|---|---|
| Standalone picker | Choose 2–4 active systems (checkboxes disable at 4) — comparison does not depend on the guided flow. |
| Matrix | Attributes 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 highlighting | Rows 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 differences | A 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-links | Each 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 data | Retired 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.