Skip to content

Media illustrations & coverage (reference)

Issue #1244. Part of the media-curation workbench (#1238).

An illustration wires the best photography to the guided flow as a teaching aid: it says "this media explains this factor/attribute value" (role = illustrates). This is distinct from:

  • a gallery attachment (media_links) — decoration on an entity, and
  • a classification tag (media_tags) — what the image depicts.

The payoff (the "goldmine"): illustrate factors and attributes so the guided flow, compare, and factor explainers can teach with real photography, not just decorate.

The association

Table media_illustrations (migration 0063):

ColumnNotes
target_typefactor_option or attribute_value — what is illustrated.
target_refa factor_options.id, or ${attributeId}:${value} for an attribute value.
media_asset_id XOR shot_group_idthe source: a single image, or a shot-group scrubber (#1240 — e.g. a folding value shown open→closed). A CHECK enforces exactly one.
rolethe semantic role; v1 is illustrates (extensible).
is_canonicalthe canonical illustration for the target value — at most one per (target_type, target_ref), app-enforced.

Domain: $lib/server/admin/illustrations.tsaddIllustration (validates the target + source exist, refuses duplicates), removeIllustration, setCanonicalIllustration (clears any prior canonical), illustrationsForTarget, and canonicalIllustration.

Canonical illustration (AC-02)

Per factor/attribute value, an admin sets one canonical illustration — a single image or a shot-group scrubber. setCanonicalIllustration(db, id) makes a row canonical and clears any prior one, so the target always resolves to a single teaching image/sequence.

Coverage report (AC-03)

illustrationCoverage(db) (illustration-coverage.ts) enumerates the whole target universe — every factor option and every attribute enum value — and reports, per value, whether it has an illustration (and a canonical one). Values with no illustration are gaps, listed first: the curation worklist, and the priority signal for description/illustration runs ("illustrate the uncovered values first"). Bounded — the catalog value tables are small and the illustration set is grouped once.

The admin surface is /admin/illustrations (Content & media): a coverage summary (factors X/Y, attributes X/Y) and gap-first lists with the canonical thumbnail per covered value. Since #1482 it is a report only — it carries no assignment controls; each row deep-links (illustrationSlotHref, $lib/admin/illustration-links.ts) to the owning entity editor's illustration slot.

Single curation path (#1482 → #1520)

Curation lives inside the entity's one Media widget (MediaManager) — the factor editor (per option, Options tab) and the attribute editor (per enum value, Values tab) mount a single always-visible widget that owns BOTH the gallery (educates the buyer during selection — guided flow, compare, heroes) and the results-page role (shows the recommended system in the buyer's setting — the recommendation page's contextual slots and the #1282 ranking floor). The former separate IllustrationSlot panel is retired (#1520).

In the widget: the gallery image carrying the role is badged results page; any other attached image offers one-click Use on results page; the role section's Manage view lists the value's illustrations (show-this-one / remove), the depicts→illustrates candidates (#1279), and the widget's own library picker in illustrate mode. Deep links (?illustrate=<targetRef>, via illustrationSlotHref) still land expanded — the coverage report's rows keep working.

Mutations ride the one media manage endpoint (/admin/media/manage, actions illustrate / illustration-canonical / illustration-clear, plus the illustration-slot read) — edit_data guarded, audited, wired through the #1244 seams above; no schema change. Read halves live in $lib/server/admin/illustration-slot.ts (illustrationSummaries, illustrationSlotProps).

Divergence audit (#1482 AC-04). illustrationDivergence(db) (illustration-coverage.ts) reports how many canonicals equal the same value's primary gallery image (flagged link, else first by position) — rendered as a summary line on /admin/illustrations. If canonicals almost always match the primary, a follow-up can derive the canonical from the gallery primary by default. Audit at 2026-07-15: the local catalog carried 0 canonical illustrations, so the report starts at zero — the line becomes meaningful as curation lands.

Public query (AC-04)

canonicalIllustration(db, targetType, targetRef) resolves a value's canonical illustration to a renderable:

ts
{ kind: 'image' | 'scrubber', coverAssetId: string | null, shotGroupId: string | null }

For a scrubber, coverAssetId is the shot group's cover frame (the default image); shotGroupId lets a surface render the full open→closed sequence. This is the query the public rendering surfaces consume — the guided flow, compare, and factor explainers (#410 / #413 / #359). Public rendering of the scrubber itself rides those issues; this req provides the association + the query.

Contextual illustration ranking (#1282)

The recommendation page's thesis is "here's the {System} we recommend in the setting you described" — so a slot shows the media that best matches that value × the recommended system × the buyer's scene, not a static canonical shot. The match/rank engine + query lives in src/lib/server/illustration-ranking.ts (the public display rides #410); the scorer is pure and unit-tested.

The query. rankIllustrationsForRecommendation(db, { systemId, optionIds }, weights?) returns { hero, answers } — one RankedIllustration for the system hero and one per buyer answer. A buyer answer id is a factor_option tag ref (#1279), so candidacy for a slot = assets carrying that value tag (curated media_tags ∪ accepted media_visual_tags), and overlap is scored from the candidate's curated tags against the scene (the recommended system + the other answers). Only publishable (cleared-rights) assets surface (#1115). It is exposed on the recommendation API as illustrationRanking for #410 to render.

attribute_value overlap has no answer bridge yet (factor_option_attributes carries no value), so answer slots are factor_option today; the engine already handles the attribute_value tag type, so it flows through once a bridge exists.

Fallback ladder (never a broken/empty slot; the chosen tier is on the result, so why this image is inspectable):

TierMeaning
exactvalue + same system + shared context
systemvalue + same system
contextvalue + shared context (any system)
canonicalthe value's canonicalIllustration (#1244)
educationno image — the value's education copy stands alone

A bare value-only tag never out-ranks the curated canonical.

System-vs-context weighting is an explicit, tunable knob (IllustrationWeights / DEFAULT_ILLUSTRATION_WEIGHTS = { system: 3, context: 2, provenanceHuman: 0.5 }), not a hardcoded guess: by default a same-system match outranks a single loose context match, but a generic system-only shot loses to a candidate that nails ≥2 context signals (the coastal-buyer decision). Tie-breaks, in order: human-curated > AI → gallery order → recency → id.