Character Reference Sheet — Redesign (design spec)
Date: 2026-06-28
Branch: feat/character-sheet-redesign
Status: approved design, twice audited against the codebase → ready for implementation plan
Problem
The Character Studio “Sheet” page (shared reference-sheet-tab.tsx) generates a composited turnaround/reference sheet, but:
- You can’t view a sheet full-screen — result + “Existing sheets” grid have only Download / Set-thumbnail.
- You can’t copy the sheet URL.
- The sheet “types” are bare, unexplained, uncosted chips — no hint of what each produces, that some need to generate angles first, or how many credits it costs.
- The clean head + body turnaround format most useful for AI video reference (front/profiles/back-of-head + front/side/back body — see attached screenshots) isn’t a clearly-offered option.
Current state (verified — reuse, don’t rebuild)
- Two-stage flow (
reference-sheet-tab.tsx): Stage AplanSheetGeneration(entityKind, sections, flavour, bucketsByColumn, name)→{ presentUrls: string[]; missing: PanelGenRequest[] }; eachmissingis generated viasheet-tab-adapter.tsand awaited; Stage BgenerateReferenceSheet(POST /v1/reference-sheet) composites. Both stages resolve panels through the SAME pure plannerpanel-plan.ts::planSheetPanels. SheetSection={ kind, board?, subtitle?, panelCount?, entries? }.entries?: SheetEntry[], preset entry{ kind:"preset", variant:string }, is the curated-panel mechanism.planSheetPanels:entries?.length→ emit exactly those variants in order; elseBOARD_VARIANTS[entity][board].slice(0, panelCount ?? DEFAULT_PANEL_COUNT),DEFAULT_PANEL_COUNT = 4.planSheetPanelsthrows if a plan exceedsMAX_PANELS_PER_SHEET = 24. SinceestimateSheetCostwill wrapplanSheetGeneration→planSheetPanels, this throw can fire on the frontend during a live-cost recompute (see §3 cap).- Route Zod schema (
reference-sheet.schema.ts):flavour.sections[].entriesalready validated;typeis a requiredz.enum(SHEET_TYPES)carrier; the layout comes fromflavour.sections(curated sections win overDEFAULT_SECTIONS[type]—plan-generation.ts:22). Theflavourobject is a plainz.object→ strips unknown keys (sopresetIdmust be added there). - Angle catalog (
entity-prompts.ts):headAngles=[front, 3/4 left, left profile, right profile, 3/4 right, above, below](7, noback);bodyAnglesincludesback. Backend angle PROMPT maps already have profiles/above/below and headback(generate-character-asset.ts:191). The variant gate is a runtime.includes(CHARACTER_ASSET_VARIANTS[assetType]), reading the same shared array. - Board counts (
BOARD_VARIANTS[character]): expressions = 11, poses = 9, wardrobe = 3, detail = 3 (default slice =min(4, n)→ Expr 4 / Poses 4 / Wardrobe 3 / Detail 3). - Cost:
reference-sheet:assembly = 4(still); each missing panel = a character-asset gen with no provider override →nano-banana = 1. No provider selector in the sheet flow → per-panel cost is constant 1.presetIddoes not change the credit id (keys offoutputFormat). MultiImageLightbox(components/ui/multi-image-lightbox.tsx): propsitems: {url,alt?,kind?:"image"|"video"}[],startIndex,onClose; ←/→/Esc; supportskind:"video"; z-[100]. Noactionsslot today. Root closes on backdrop click → action buttons muste.stopPropagation().ReferenceSheetrecord has NOname/presetId. The worker hardcodessource:"node"for ALL sheets (studio included) and stores the inputflavourverbatim; the grid labels by${type} · ${skin}and does not filter bysource(so never filter bysource==="studio"— it would hide everything).- The character adapter’s
awaitJobonly resolves a URL — it does not updatestagedbuckets; the studio refetches the row only on open. (Basis for the §3 prepared-key tracking.)
Goals
Character-first (the tab is shared by character/object/location; the redesign is gated to character — see §2 guard):
- Full-screen viewing of sheets with ←/→ navigation.
- Copy-URL on sheets.
- Named, explained, live-costed presets: Studio · Main, Studio · Extended, plus à-la-carte board add-ons.
- Two-step generation: ① Prepare angles (generate only what’s missing; angles persist as reusable assets) → ② Compose sheet, with transparent cost.
Design
1. Full-screen + Copy-URL
- Clicking the result image or any “Existing sheets” tile opens
MultiImageLightboxwithitems=[result, …existingSheets](←/→ pages across all). The builder setskind: sheet.flavour?.outputFormat === "motion" ? "video" : "image"(motion sheets are mp4s; an<img src=…mp4>would render broken). - Add an optional
actions?: (item) => ReactNoderender-prop toMultiImageLightbox(opt-in; ~10 existing call sites pass nothing → unaffected). The Sheet tab passes a “Copy URL” action. All action buttonse.stopPropagation()(the lightbox root closes on backdrop click). - Copy-URL (
navigator.clipboard.writeText(url)+ toast): next to Download on the result, in the lightbox viaactions, and on each existing-sheet hover row.
2. Preset model — entries-based, character-only
New packages/shared/src/reference-sheet/presets.ts — the SoT for the preset cards:
type SheetPresetId = "studio-main" | "studio-extended"
interface SheetPreset {
id: SheetPresetId
label: string // "Studio · Main"
description: string
type: SheetType // CARRIER for the route's required `type` enum (Main→"turnaround", Extended→"full-reference")
baseSections: SheetSection[] // curated via `entries`
skin: SheetSkin // "studio"
aspect: SheetAspect // "landscape"
}
const presetEntries = (vs: string[]): SheetEntry[] => vs.map((variant) => ({ kind: "preset", variant }))
const PRESET_LABELS: Record<SheetPresetId, string>
studio-main— “Clean turnaround for AI video reference.” carrierturnaround.{ kind:"head-turnaround", entries: presetEntries(["front","left profile","right profile","back"]) }{ kind:"body-turnaround", entries: presetEntries(["front","left profile","back"]) }- 7 panels,
studio/landscape. (= screenshots 1 & 2.)
studio-extended— “Full turnaround — adds above/below + 3/4 angles.” carrierfull-reference.- head entries:
front, 3/4 left, left profile, right profile, 3/4 right, above, below, back(8) - body entries:
front, 3/4 left, left profile, right profile, back, above, below(7) - 15 panels,
studio/landscape.
- head entries:
Every variant exists in headAngles/bodyAngles except head back → the single catalog edit below. (Layout note: Main head = 4 panels renders as a single row (matches the screenshots); Extended head = 8 wraps 5+3 in the landscape grid — acceptable for the “more coverage” preset.)
À-la-carte boards — optional toggles appended to the base preset’s sections (no entries → board’s default slice):
| Toggle | Section kind | Board | Default panels |
|---|---|---|---|
| Expressions | expression-board |
expressions | 4 |
| Poses | pose-board |
poses | 4 |
| Wardrobe | wardrobe-board |
wardrobe | 3 |
| Detail (eyes/hands/hair) | detail-board |
detail | 3 |
| Palette | palette |
— | 0 (structural, free) |
🔴 Panel-count cap (required). Studio·Extended (15) + à-la-carte can exceed MAX_PANELS_PER_SHEET = 24 (Extended + 3 panel-boards = 26; + all 4 = 29) → planSheetPanels throws, and because the live cost calls it, the throw surfaces in the cost handler, not just a route 400. So:
- The board toggles are disabled (greyed, with a “would exceed 24 panels — remove a board” hint) once the projected plan would exceed 24.
estimateSheetCost’s call toplanSheetGenerationis wrapped in try/catch; on overflow the cost line shows “Too many panels — remove a board” instead of crashing.Studio·Main (7) + all 4 boards = 21is always safe; only Extended is exposed.
Object/Location guard (required). head-turnaround/body-turnaround boards don’t exist for object/location (resolveBoard throws). The preset UI renders only when adapter.entityKind === "character"; object/location keep the existing SHEET_TYPES chip UI unchanged. For character, the presets + à-la-carte boards are a superset of the old character types (turnaround / variation-board≈Expressions / detail≈Detail / full-reference≈Extended+boards), so the chip UI is replaced there. (Deliberate simplification: character loses the rarely-used “expression-board alone, no turnaround” option; acceptable per the “simplify” goal.)
Single catalog edit: append "back" to CHARACTER_ASSET_VARIANTS.headAngles (entity-prompts.ts) at the end (index 7) — keeps the default-4 slice [front, 3/4 left, left profile, right profile] unchanged (no test churn), enables the catalog AND satisfies the route’s .includes gate; the head prompt already has back.
3. Two-step flow + live cost
New packages/shared/src/reference-sheet/cost.ts:
function estimateSheetCost(
entityKind, sections, flavour, buckets, name, perPanelCost, assemblyCost,
): { present: number; missing: PanelGenRequest[]; prepareCost: number; assemblyCost: number; total: number; overflow: boolean }
// present = planSheetGeneration(...).presentUrls.length; missing = .missing
// prepareCost = missing.length * perPanelCost; total = prepareCost + assemblyCost
// wrap planSheetGeneration in try/catch → overflow:true (MAX_PANELS) instead of throwing
UI (recomputed live as the user toggles preset/boards and as panels get prepared):
┌ Studio · Extended ──────────────────────────────┐
│ Full turnaround — adds above/below + 3/4 angles. │
│ Reuses 9 existing angles · 6 missing │
│ ① Prepare 6 angles ~6 cr ② Compose 4 cr │
└─────────────────────────────────────────────────┘
- ① Prepare angles — generates ONLY the missing panels (Stage A:
adapter.generateAssetpermissing, awaited; worker attaches each to the entity bucket → also visible in the Angles tab). Hidden when nothing missing.N/Mprogress. - ② Compose sheet —
generateReferenceSheetwith the preset’s carriertype+ explicitflavour.sections(base + toggled boards) +flavour.presetId. Poll → surface result. Enabled only whenmissing.length === 0, else “Prepare N angles first.” Stage B re-reads fresh DB buckets, so it sees panels prepared this session. - Prepared-key tracking (required): the character adapter’s
awaitJobreturns only a URL and never updatesstaged, and the studio refetches only on open. So as ① completes, accumulate each prepared panel as{ column: missing[i].attachToColumn, name: missing[i].attachName, url }(the URL fromawaitJob) and merge into the column-keyed bucket snapshot fed toplanSheetGeneration/estimateSheetCost:buckets[column] = [...(buckets[column] ?? []), { name, url }].matchVariantrequires a non-empty URL, so the captured URL must be injected (a synthetic placeholder is safe — Stage B re-resolves from DB). Key byattachToColumn, NOTassetType(headAngles→columnangles; detail/wardrobe are bothcustombut land indetail_closeups/outfit_variations) — otherwise the merge writes to a column the planner never reads → Compose never enables.
4. Cost gating + edition boundary
- The live-cost readout is gated behind
hasCredits()(frontend/src/lib/edition.ts) — never renders in community/business. - To keep the core tab ee-import-free,
perPanelCost(1) +assemblyCost(4) are constants surfaced viasheet-tab-adapter.ts(core), not the eeuseModelCreditshook.
5. Preset labeling (persisted, no worker change)
- Add
presetId?: SheetPresetIdtoSheetFlavour(reference-sheet/types.ts) AND to theflavourZod schema (reference-sheet.schema.ts) — the only place it would otherwise be stripped.api.tsalready forwards the full flavour and the worker stores it verbatim, so it round-trips with no worker change. - The result + grid + lightbox label via
PRESET_LABELS[flavour.presetId], falling back to${type} · ${skin}for legacy/non-preset sheets. Do not filter the grid bysource.
6. Style controls (secondary)
In the character preset UI, a compact “Style” row keeps skin (studio default; cinematic/blueprint/illustrated) + aspect + the Title-metadata / Panel-labels toggles. Presets set defaults (studio/landscape); user can override. (Object/location keep their full existing chip UI.)
Files touched
Shared (packages/shared/src/reference-sheet/) — rebuild dist after (auto via frontend pre* hooks; a mid-session shared edit needs a manual npm -w @nodaro/shared run build).
presets.ts(new) —SHEET_PRESETS,presetEntries,PRESET_LABELS,SheetPresetId, à-la-carte board list.cost.ts(new) —estimateSheetCost()(try/catch MAX_PANELS).types.ts— addpresetId?: SheetPresetIdtoSheetFlavour.index.ts— export the new modules.../entity-prompts.ts—headAngles += "back"(append).
Backend
routes/reference-sheet.schema.ts— addpresetId: z.enum(["studio-main","studio-extended"]).optional()toflavour. (No worker / nogenerate-character-asset.tschange.)
Frontend (components/editor/)
reference-sheet/reference-sheet-tab.tsx— character branch: preset cards + à-la-carte toggles (cap-disabled past 24) + Style row +hasCredits()-gated cost (viaestimateSheetCost+ prepared-key tracking) + ① Prepare / ② Compose; non-character branch: existing chip UI unchanged;MultiImageLightbox(result + existing sheets, motion→kind:"video") with theactionsCopy-URL slot; Copy-URL on result + tiles;PRESET_LABELS.reference-sheet/sheet-tab-adapter.ts— exposeperPanelCost(1) +assemblyCost(4).ui/multi-image-lightbox.tsx— optionalactions?: (item) => ReactNodeslot (default none).
No new credit SKUs.
Testing
- Shared:
SHEET_PRESETSpanel-set tests (Main → 7 ordered variants; Extended → 15);estimateSheetCost(present/missing combos; all-present → prepareCost 0; MAX_PANELS overflow →overflow:true, no throw);entriesresolution (curated subset, not default-4);headAnglescontainsbackandslice(0,4)unchanged. - Backend: route accepts
flavour.presetId; pricing/credit-id unchanged. - Frontend: character tab — preset selection updates cost; board toggles disable at the 24 cap; Compose disabled until prepared-key merge clears
missing(column-keyed); Copy-URL writes clipboard; lightbox opens with ←/→, renders Copy-URL action (stopPropagation), motion sheet as video; cost readout absent when!hasCredits(); object/location tab unchanged (still chips).
Out of scope (now)
- Object/Location presets (the
entriesmechanism supports them; a follow-up). - Motion (video) sheet generation — unchanged (but motion sheets must display correctly in the new lightbox).
- Per-provider dynamic panel pricing (constant
nano-banana = 1).
Resolved
- Curated subsets via the existing
entriesfield (verified through planner + both stages + Zod). - Single catalog edit
headAngles += "back"(headbackprompt already exists; index-7 append keeps tests green). - Carrier
typeper preset;presetIdpersists viaflavour(no worker change). - Panel cap + prepared-key column-merge close the two blocking issues found in review.