Skip to content

0044 — Card mode resolution (why illustrations never render)

Status: IMPLEMENTED 2026-07-27. Closes issue #1. Shipped: reel.RenderMode (the card → theme → block resolver, with overlay-without-media degrading rather than failing), the build wired to it plus stderr notices for a dropped or ignored illustration, and cards pick/cards set now setting overlay on a mode-less card. Validation is deliberately unchanged (D4). Root-causes issue #1 (reel build renders the caption on the theme's flat base colour instead of compositing the selected card illustration). Two independent gaps, either of which alone produces the symptom. Date: 2026-07-27 Relates: 0001 §6 (themes are config-driven) · 0013 (the pick/promote engine) · 0020 (reel make) · 0038 (reel text layout) · issue #1.


1. The report, and what is actually wrong

Issue #1 reports that every card frame is flat cream with only the caption drawn — no illustration, no scrim — on a hand-authored workspace and on a bundled reference one, in both card modes.

The card renderer is not at fault. renderOverlay (internal/render/cards/cards.go:104) loads and composites the panel correctly; a new TestRender_overlayCompositesMedia confirms a magenta panel survives into the output pixels. The failure is entirely in how a card's mode is decided before the renderer is called.

reel build only passes the illustration through when both conditions hold (pkg/cmd/reel/build/main.go:329):

if card.Mode == reel.ModeOverlay && card.Media != nil {
    media = resolveRelative(card.Media.Path, ws)
}

and a card's effective mode defaults to block when unset (internal/reel/validate.go:46). Block mode renders caption-on-palette by design. So anything that leaves Mode unset silently produces exactly the reported frame — no error, no warning.

Two things leave it unset:

Gap A — a theme's card.mode is inert

theme.Card.Mode exists and is documented as "overlay (full-bleed illustration + scrim + text) or block" (internal/theme/theme.go:60). Nothing reads it. The only consumer in the tree is theme show, which prints it (internal/themecmd/themecmd.go:249). Every house reel theme sets card.mode: overlay; that setting has never had any effect.

This is the more serious half: the configuration surface exists, is documented, is populated in the seeded themes, and does nothing.

Gap B — picking an illustration does not make the card an overlay

wireCardMedia (internal/takescmd/takescmd.go:274) — the shared pick/upload path — sets Media and stops:

sb[card-1].Media = &reel.Media{Kind: reel.MediaImage, Source: source, Path: rel}

So cards pick produces a card that has an illustration and is not in the mode that renders one. The storyboard is internally inconsistent, and the build resolves that inconsistency by silently discarding the image.

Why the reporter's workarounds could not have worked

  • Setting img: cards/NN.pngthere is no img field. The schema field is media (internal/reel/storyboard.go:97), a *Media object. An unknown YAML key is ignored, so this was a no-op.
  • "…and removing the media field" removed the only field the build reads.
  • Trying mode: block — correct behaviour; block never composites.

The reporter was not doing anything wrong. Every observable signal said the illustration was selected, and nothing anywhere said "this card is in a mode that ignores it".

2. Design

2.1 Resolve mode through the theme, with a safe fallback

Introduce one resolution point, used by every consumer of a card's mode:

effective mode = card.Mode            (explicit per-card wins)
              ?? theme.Card.Mode      (the theme's intent — Gap A)
              ?? block                (built-in default, unchanged)

then degrade: if the effective mode is overlay but the card has no media, fall back to block rather than failing.

That last clause is essential and is why this is not a one-line fix. Today renderOverlay errors on empty media ("overlay card has no media to render"). Honouring a theme's card.mode: overlay without degradation would turn every card that has no illustration yet — the normal state mid-authoring — into a hard build failure. Degrading keeps reel build usable on a partially illustrated board, which is the common case.

2.2 Make the drop visible

Silence is what made this expensive. When a card degrades from overlay to block for want of media, reel build should say so, once per card, on stderr:

card 3: no illustration selected — rendering as a text block

reel plan / reel make already reason about Mode == overlay && Scene != "" (internal/reelcmd/plan.go:151, pkg/cmd/reel/make/plan.go:135); those call sites must move to the same resolver so plan and build agree.

2.3 Picking an illustration implies overlay (Gap B)

wireCardMedia sets Mode = ModeOverlay when it wires an image, unless the card explicitly says block. Wiring media onto a block card is incoherent — block ignores it — so the pick should either make the card overlay or refuse.

3. Decisions — RESOLVED with Matt 2026-07-27

  • D1 — The theme default APPLIES RETROACTIVELY. Existing boards start rendering overlay wherever media exists. The current behaviour is the bug, so grandfathering it would preserve the bug — and because of Gap B essentially every picked card today is "block with media wired", so retroactive application is precisely what turns them into what was meant. The §2.1 degradation clause means nothing breaks: a card without media still renders block.
  • D2 — mode: block + media WARNS, it does not error. Strict validation would reject boards currently in that state, which — because pick never set the mode — may be all of them. Fix it at the source (§2.3, the pick sets the mode) and warn on the incoherent leftovers.
  • D3 — Degrade + warn now; --strict later. For the unattended publish path a silently text-only reel is a bad publish, so --strict earns its place — but that path is gated on 0042/0010 anyway, so it follows rather than blocks.
  • D4 — The theme default applies to RENDERING, not VALIDATION (found while implementing). R-WS-10 makes overlay + no scene + no media a validation error (internal/reel/validate.go:80). If the theme default fed validation, a theme with card.mode: overlay would make every plain text card invalid at save time — including through the studio. So Card.mode() keeps its card-only, block-defaulting behaviour for validation, and the theme-aware resolver serves the render/plan path. Degradation (§2.1) is what makes that split safe: the build never renders an overlay it has no media for.

4. Scope / non-goals

  • Not a renderer change. internal/render/cards is correct and now has a pixel-level regression test.
  • Not a schema change. media stays the field; adding an img alias would reward the wrong guess.
  • The studio's own mode control (SceneConfig) already writes Mode explicitly, so it is unaffected — but it must adopt the same resolver so its live preview matches the build.

5. Test plan

  • Unit (resolver): card wins over theme; theme applies when the card is unset; block when neither; overlay-without-media degrades to block.
  • Unit (pick): cards pick / cards set leave the card in overlay with media wired; a card explicitly block is respected (or refused, per D2).
  • Build: a workspace whose theme sets card.mode: overlay and whose card has a picked illustration renders the illustration — the regression issue #1 describes; and a card with no illustration renders as block with one warning.
  • e2e (godog): pick an illustration, build, assert the frame is not the flat palette colour — the end-to-end assertion whose absence let this ship.