Skip to content

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-md corner, a --ns-color-border-subtle hairline, a --ns-shadow-sm lift, and a --ns-color-surface-inset backing 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 passes check:tokens.
  • Optional interactive — a subtle hover elevation for framed media that links somewhere; motion is disabled under prefers-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.

PresetRatioUsed for
landscape (default)4 / 3Galleries, hero slides, showcase, compare heads
portrait3 / 4Tall product shots
square1 / 1Compact thumbs (selection-flow alternatives)
wide16 / 9Cinematic 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' with embedSrc, #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 imageMediaFrame + ResponsiveImage (sized renditions, framed);
  • a Wistia still that plays (embedSrc set on a type:'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 never VideoThumbnail, 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.

ModePresentation
gridAuto-filling framed tiles. A lone image is capped (.grid.single, ≤ 22rem) so it reads as a feature, not a stretched banner.
slideshowOne framed hero slide with prev/next controls, manual advance only (safe under reduced motion by construction).
singleOnly 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.

SurfaceEntityPlacement
MediaGallery (src/lib/media/)systemGrid tiles and the slideshow hero — the recommendation's full gallery in the selection flow.
Landing showcase (src/routes/+page.svelte)systemSystem cards in the right-sizing story.
Home persona cards (src/routes/+page.svelte)trackHero above each published track card; a track without media stays text-only.
Home "projects like yours" (src/routes/+page.svelte)projectContext-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)systemOne hero thumb per compared system.
Compare attribute rows (src/routes/compare/+page.svelte)attribute, enum_valueAttribute 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_optionThe factor's educational visual above the options; a thumbnail beside each choice.
Selection-flow alternatives (src/routes/select/SelectionFlow.svelte)systemSquare 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 system tags become entity media_links (the #360 attach bridge). That attach used to be a separate manual admin step; the /admin/drupal-sync Apply gallery images action now runs it in the same pass (runSyncAndActivate in src/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 from media_tags via projectPrimaryImages — so activation bridges only the system side.
  • Two resolution paths. System surfaces (compare heads, showcase, recommendation galleries) read media_links through primaryEntityImages / listEntityMedia. Project surfaces ("projects like yours", on the recommendation and the home front door) read media_tags (project) through projectPrimaryImages. Both return null/[] 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-sync carries a Media coverage card (media-coverage.ts) reporting which active systems and which projects lack an image — an image media_link for systems, an image project tag 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 carry alt="" by the admin's explicit opt-out; informative images carry their altText. The frame is presentational and adds no ARIA of its own.
  • Keyboard — the slideshow's prev/next are the tab stops; //Home/End advance while either is focused, :focus-visible shows a --ns-color-focus ring, and the slide counter is an aria-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_ASPECTS in frame.ts (and a frame.spec.ts case) — don't hardcode aspect-ratio at a call site.
  • Style only through --ns-* tokens; npm run check fails on a raw color or radius, or an unknown token.
  • Frame images through MediaFrame; leave video to VideoEmbed.
  • Update this doc when a presentation convention changes (the AC-5 contract).