Appearance
Media presentation
How galleries and hero imagery are presented on the public site (#362, part of the Entity Presentation capability #359). The goal is that media reads as one cohesive system across every public surface — not an ad-hoc treatment per page. It sits on top of the media pipeline (#182) and the entity-imagery wiring (#245).
The primitives live in src/lib/media/. This doc covers the presentation layer; the admin-side authoring flow is in managing-media.md.
The framing convention
MediaFrame.svelte is the single treatment every framed image passes through, so a mixed set of source aspect ratios still tiles uniformly:
- Fixed aspect box +
object-fit: cover— the image fills a stable frame instead of dictating its own height, so grids and rows stay even. - One consistent surface — a single
--ns-radius-mdcorner, a--ns-color-border-subtlehairline, a--ns-shadow-smlift, and a--ns-color-surface-insetbacking that shows through while an image loads (never a bare-page flash). Everything is a--ns-*token, so it restyles with a theme switch and passescheck:tokens. - Optional
interactive— a subtle hover elevation for framed media that links somewhere; motion is disabled underprefers-reduced-motion.
Aspect presets
Framing ratios are named in frame.ts (the single source of truth) and resolved by frameAspect(), which also accepts an explicit "W / H" and falls back to the default on anything malformed — a typo never ships a collapsed frame. The resolver is pure and unit-tested (frame.spec.ts); the project's vitest runs server specs only, so presentation logic that matters is kept testable here rather than in a component render.
| Preset | Ratio | Used for |
|---|---|---|
landscape (default) | 4 / 3 | Galleries, hero slides, showcase, compare heads |
portrait | 3 / 4 | Tall product shots |
square | 1 / 1 | Compact thumbs (selection-flow alternatives) |
wide | 16 / 9 | Cinematic placements |
Video is never cover-cropped. VideoEmbed.svelte keeps its own 16/9 frame (cropping a player would hide controls); its radius and surface already match MediaFrame, so the set still reads as one system.
Third-party embeds are deferred until click (consent-safe). No hosted-video player is mounted on page load — the provider iframe is contacted only when the viewer clicks play. Two facades enforce this, both swapping to VideoEmbed on click (#361 AC-3):
- a Wistia still that plays (
type:'image'withembedSrc, #400) →VideoThumbnail(the still is the poster); - a pure hosted video (
type:'video', e.g. a Wistia embed) →VideoFacade, which renders the provider-derived poster (posterSrcFor— for Wistia the public swatch endpoint) over a branded 16/9 surface and mounts the iframe only on click (#1928). No derivable poster, or a poster that fails to load, degrades to the branded gradient — never an error (public tier degrades).
The branded play button is one shared component. Both facades center PlayButton.svelte (#1928) — the nanawall.com treatment (nanawalld8 .playbutton): a 64px circular surface-colored disc with an accent-colored triangle, --ns-shadow-lg drop shadow, and a hover state (scale + --ns-color-accent-hover fill) the owning button drives via the inherited --play-scale / --play-fill custom properties. On activation both facades keep their still up — blurred, with the button dimmed — until the player iframe reports ready (VideoEmbed's onReady), then reveal the player in place, mirroring the nanawalld8 loading choreography.
So a page with hosted video makes no third-party network contact and never autoplays until an explicit user action — the privacy posture #346/#347 expect for embeds. Embed hosts are already privacy-first (youtube-nocookie.com, Vimeo ?dnt=1, fast.wistia.net). Wistia embeds carry the brand player option as an embed-URL param (playerColor=154239, #1928) — the same value nanawalld8 passes its async embeds — keeping the lightweight iframe approach (no 1.9 MB E-v1.js runtime).
The single-item dispatcher (EntityHeroMedia)
Buyer surfaces that show one representative image per entity (a card, a step, a choice, a row header) render it through EntityHeroMedia.svelte (#411) — the single seam so every hero/thumbnail across the site behaves identically. It takes the item primaryEntityImages returns and dispatches:
- a plain image →
MediaFrame+ResponsiveImage(sized renditions, framed); - a Wistia still that plays (
embedSrcset on atype:'image'asset, #400) →VideoThumbnail(the still loads the player on click — a still that plays, the AC-5 contract); - a pure hosted video (
type:'video') →VideoFacade(#1928 — the provider poster + branded play button; never a bare player, and neverVideoThumbnail, which needs a still the item doesn't have); - a null item → renders nothing (no broken image, no reserved gap).
primaryEntityImages returns the primary image (or first by position), so a pure iframe-only type:'video' is normally not selected for hero surfaces — but when one does arrive (e.g. an education placement whose only asset is a video), it renders as the poster + play facade, not a dead tile. MediaLightbox enlarges a video item the same way (#1928).
Display modes (admin-owned)
Per-gallery grid, slideshow, or single is the admin's choice (#182, single mode added in #413), carried on the view model as gallery.displayMode and honored verbatim in MediaGallery.svelte. grid is the default when unset. The framing convention changes only what happens within a mode — it never overrides the admin's layout choice.
One deliberate exception — the results recommendation card (#1006). The Recommendations view is the payoff screen, so its recommended-system gallery always leads with one large image at a time, never a thumbnail grid: RecommendedSystemCard.svelte overrides displayMode to slideshow (or single when the system has a lone image, so it shows cleanly with no carousel chrome), regardless of the admin's per-system setting. Every other surface still honors gallery.displayMode verbatim.
| Mode | Presentation |
|---|---|
grid | Auto-filling framed tiles. A lone image is capped (.grid.single, ≤ 22rem) so it reads as a feature, not a stretched banner. |
slideshow | One framed hero slide with prev/next controls, manual advance only (safe under reduced motion by construction). |
single | Only the primary image (or the first in position order when none is flagged), rendered as a capped hero (≤ 32rem). A video-primary gallery still plays via the shared media dispatcher. |
Where it's used
Attached entity media (media_links) now renders on every public surface an entity appears on (#411), not just systems — rule media stays out of public scope. Full galleries go through MediaGallery; single hero/thumbnail placements go through EntityHeroMedia.
| Surface | Entity | Placement |
|---|---|---|
MediaGallery (src/lib/media/) | system | Grid tiles and the slideshow hero — the recommendation's full gallery in the selection flow. |
Landing showcase (src/routes/+page.svelte) | system | System cards in the right-sizing story. |
Home persona cards (src/routes/+page.svelte) | track | Hero above each published track card; a track without media stays text-only. |
Home "projects like yours" (src/routes/+page.svelte) | project | Context-matched real installations for a returning visitor — resolved from media_tags (project) via projectPrimaryImages; absent for a new visitor or when the answers match no installed project (#453). |
Compare column heads (src/routes/compare/+page.svelte) | system | One hero thumb per compared system. |
Compare attribute rows (src/routes/compare/+page.svelte) | attribute, enum_value | Attribute icon beside the row label; an enum row's "What these values mean" legend teaches each allowed value's education + media. |
Guided step (src/routes/select/SelectionFlow.svelte) | factor, factor_option | The factor's educational visual above the options; a thumbnail beside each choice. |
Selection-flow alternatives (src/routes/select/SelectionFlow.svelte) | system | Square thumb per cost-effective alternative. |
Activating synced media across the journey (#453)
The media ingestion pipeline (#400) syncs Drupal imagery and organizes it with system / project tags. Activation is making that synced imagery actually surface, real and relevant, across the content journey — the presentation seams above are the destination; these are the wiring that fills them.
- Auto-activate on sync. A system gallery only renders once its
systemtags become entitymedia_links(the #360 attach bridge). That attach used to be a separate manual admin step; the/admin/drupal-syncApply gallery images action now runs it in the same pass (runSyncAndActivateinsrc/lib/server/drupal/media-activate.ts), so a sync that brings in new system media activates it immediately. Project media needs no attach — it resolves straight frommedia_tagsviaprojectPrimaryImages— so activation bridges only the system side. - Two resolution paths. System surfaces (compare heads, showcase, recommendation galleries) read
media_linksthroughprimaryEntityImages/listEntityMedia. Project surfaces ("projects like yours", on the recommendation and the home front door) readmedia_tags(project) throughprojectPrimaryImages. Both returnnull/[]for an entity with no imagery, so the surface renders without it. - Context on the home front door. For a returning visitor with stored answers, the home load recomputes their recommendation and resolves "projects like yours" — real installations that used the recommended/alternative system — each with its synced project image. New visitors and no-match contexts simply omit the strip (static-first: a failure never breaks the front door).
- Coverage visibility. Missing imagery degrades silently on the buyer surfaces, so
/admin/drupal-synccarries a Media coverage card (media-coverage.ts) reporting which active systems and which projects lack an image — an imagemedia_linkfor systems, an imageprojecttag for projects (video-only counts as missing, since the surfaces render stills). That turns an invisible gap into an actionable list.
Accessibility & graceful degradation
- Alt text flows through
ResponsiveImage: decorative images carryalt=""by the admin's explicit opt-out; informative images carry theiraltText. The frame is presentational and adds no ARIA of its own. - Keyboard — the slideshow's prev/next are the tab stops;
←/→/Home/Endadvance while either is focused,:focus-visibleshows a--ns-color-focusring, and the slide counter is anaria-live="polite"region. Controls are a comfortable ≥ 2.5rem touch target. - 1 · N · 0 images — N tiles evenly; a lone image is capped; zero renders the "No media attached" empty state. Every dynamic media section degrades to nothing when its data is empty.
Conventions when changing presentation
- Add a new ratio to
FRAME_ASPECTSinframe.ts(and aframe.spec.tscase) — don't hardcodeaspect-ratioat a call site. - Style only through
--ns-*tokens;npm run checkfails on a raw color or radius, or an unknown token. - Frame images through
MediaFrame; leave video toVideoEmbed. - Update this doc when a presentation convention changes (the AC-5 contract).