Component parts, control and library: decisions log¶
Wave two of the Betta component catalogue (PR #1333, branch
feature/neo-1984-component-library-v1). Each entry: the decision, the alternative
rejected, and why. Written as the run went; condensed into the PR body at the end.
Phase 0¶
- Baseline. 66 stills (22 components x frames 74, 6, 50) at 16:9 on
#cfd8e3inpackages/retail-studio-compositions/out/baseline/<name>-f<frame>.png, rendered throughpnpm render-componentunchanged. Compare script is a scratchpad Python (PIL + numpy) counting differing RGBA pixels per image; both scripts live in the session scratchpad, nothing committed. Baseline verification before any change: typecheck, lint, 949 tests, 10 templates valid, all green. - Shipped templates are unaffected by
parts. Checked everytemplates/*/document.json: onlybetta-component-library(regenerated from the presets) references any of the 22 new components;betta-red-hot-*reference the pre existingbetta-price-box/betta-payment-opt-baronly. So requiring a migrated component's declared parts to be present cannot break a published document.
Phase 1: the parts schema¶
- Part
typeis an enum, not a discriminated union.type: z.enum(['boxText', 'pointText', 'image', 'shape'])on one part object. Alternative: az.discriminatedUnion('type', ...)with one member per type. Rejected: at the schema package level a part'spropsare opaque (same as componentprops), so the members would be identical and the union would only add introspection work for the schema walkers (schemaNodeAtPath,expectedTypeForPath) with nothing to discriminate. The type drives the editor's per type field rendering, which reads it as a value, not a schema branch. Kept to the four layer types a part can sensibly be in v1; widening is one enum entry. hiddenis a part level field, not a part prop.hidden: rs.boolean()sits besideprops/animations/effects/transitionson the part entry. The handoff names it "a part level visibility", and no existing layer field expresses "painted away, no reflow" (layout opacity would need an animation or a layout block, which a part deliberately has neither of). Same shapepartStyleSchemaalready uses for a hidden text span.- Part schemas are resolved through a second injected resolver,
componentParts(name) => ComponentPartDefinition[], next to the existingcomponentSchema(name).ComponentPartDefinition = { id, type, schema }. Alternative: fold parts into the component props schema resolver's return value. Rejected:componentSchemais consumed by five walkers as "the ZodType forprops"; changing its return shape would touch every caller and the web app'scomponentPropSchemaexport for no gain. A sibling resolver keeps every existing caller byte identical and lets callers without part knowledge (the API's validate path today) keep working. - One helper builds a concrete component layer schema for a given layer
(
concreteComponentLayerSchema):componentLayerSchemawithpropsswapped for the component's real schema andpartsswapped for az.objectkeyed by the declared part ids, each with itspropsswapped for the part's schema. The five schema walkers (resolveAspectRatio,collectPerRatioBlocks,withCanvasRatios,generateManifest,validateTemplate) all descend that one schema, soparts.<id>.props.<...>bindings, per ratio blocks inside part props, and control declarations resolve exactly likeprops.<...>does today. Rejected: special casingpartsinside each walker'srecordbranch (five copies of the same rule). - Validation of parts (with the resolver supplied): an undeclared part id, a
typethat differs from the declaration, and a declared part that is absent are all rejected. Absent is included because the render host readslayer.parts[id]and a missing part would throw at render (a paid Lambda) rather than at publish. Without the resolver the check is skipped, mirroringknownComponentNames. - Part transition and effect windows are component local.
delay,startFrame,durationInFrameson a part's refs count from the component's own frame 0, the same basisEffectComponentProps.frameand the oldwipeIn.delayused. Not scene relative: a part never knows where its layer starts.
Phase 2: hosting and migration¶
- The pulse stays a per part boolean prop (
pulse: true) plus the component widepulsetiming at the top level. The handoff suggested "a looping return style animation". CheckedreturnAnimationSchema: it is one there and back (durationInFrames: { from, hold, to }), no repeat. Expressing the pulse as a part animation would need a new looping primitive in the animation schema (a change to every layer type's animation contract, well past this brief), would lose the shared beat (each part would carry its own copy of the timing, free to drift), and RetailStudio has no per layer animation authoring control (only Transitions and Effects), so the customer facing toggle would be bespoke either way. The shared pivot origin is render code, as the part system doc says. wipeInbecomestransitions.in = { type: 'remotion', name: 'wipe', ... }andfadeInbecomestransitions.in = { type: 'remotion', name: 'slide-fade', ... }, both registered shared layer transitions. Pixel parity: the wipe is identical (sameeasingCurve, samewipeClipPath, same delay/duration basis). The slide-fade differs mid entrance only: the shared transition fades opacity over the FIRST HALF of the slide (clamp01(progress * 2)), the bespokefadeInfaded over the whole slide (progress). Adopted anyway: the brief's whole point is the shared transition vocabulary, the end state is identical, and only frames inside a fade window (frame 6 for parts whose fade has started) can differ. Listed per still in the Phase 2 gate below.offset(pixel nudge) androtationstay part props. A part has no layout block by design (position is the one thing never exposed), so there is nowhere else for a tilt or a nudge to live.offsetis advanced;rotationis standard (the brief's checklist names rotation).- A component declares its parts in
schema.ts(Remotion free) asexport const <name>Parts: ComponentPartDeclaration[]with{ id, type, label, schema, standard }wherestandardlists the prop paths shown by default (the rest are advanced).index.tsxreferences the same array onEffectEntry.parts;prop-schemas.tsaggregates them intoCOMPONENT_PARTSfor the web app and the schema package's resolver. One declaration, three readers. - Presets become
{ typography?, props, parts }(ComponentPreset), no longer a flat props object withtypographymixed in. Every preset consumer (render-component, the Studio fixture, the test kit, the disk template builder) reads the three keys explicitly instead of destructuringtypographyout of a flat bag. EffectComponentPropsgainspartsandfps.partsis the resolvedlayer.parts(flat per ratio).fpsis needed because a part transition witheasing: 'spring'runs a real Remotion spring, and the test kit renders outside a Remotion composition whereuseVideoConfig()throws (verified). Threaded as a prop rather than read from a hook for that reason.ContentEffectssplit into a hook freeContentEffectsAt({ frame })plus the existingContentEffectsthat readsuseCurrentFrame(). A part's effects render throughContentEffectsAtwith the component local frame, so the same effects stack implementation serves layers and parts and the test kit can render a part with effects.useCurrentFrame()throws outside a composition (verified).- A lean
content-effects-registry.ts(brightness, grayscale, tint, blur, shadow, glow, the static shimmer) is whatContentEffectsresolves against, mirroringlayer-transitions-registry.ts. Forced by a module cycle: PartHost renders a part's effects through ContentEffects, which resolved through the full registry, which statically imports every component — a component importing PartHost evaluated the registry while its own module was still initialising and hit a TDZ error on its own entry (seen in the first test run). The full registry composes the lean one, so the registered set is unchanged. componentParts(name)is derived from the registration, not a hand list. Every other map inprop-schemas.tsis hand maintained because its source (the EffectEntry) is Remotion bound; here the source (schema.ts) is already Remotion free, so the one liner overcomponentPartsOf(componentPropSchema(name))cannot drift. The one cast (definition to declaration) is documented at that line.- Both references pixel identical. black-friday-banner and text-box migrated by hand as the patterns the lanes copy: 6/6 stills identical to baseline (frames 74, 6, 50). The tagline's slide-fade starts at frame 14, so frame 6 does not exercise the accepted opacity difference.
- Migration fan out: five lanes (banners; bonus cashback family; price and discount
modules; image bearing plus home appliance; flag, cover, plain text), each owning only its
component folders, each running its own tests and pixel compare against
out/baseline/. - Fan out failed, work recovered from a stray stash. All five migration lanes were killed by
an API rate limit part way through. One of them had run a bare
git stash(against the brief), which reverted every tracked Phase 2 edit while leaving the untracked new files; the stash entry (8d4a8ca3) held my hosting layer plus three finished banners and two finished bonus cashback components. Restored withgit stash apply <sha>and dropped by ref; the remaining 15 components were migrated by hand in this session rather than re fanning out. - Render nondeterminism, not a migration delta.
betta-bonus-cashback-tiersframe 74 differed from baseline by 95 pixels (max channel delta 1) on the first render and was byte identical on a second render of the same code. Attributed to swangle anti aliasing jitter on a tilted panel at a fractional pulse scale; the gate treats a max delta of 1 that disappears on re render as identical. declarePartsiterates the schema map. Part order (the panel order) is the key order of<name>PartSchemas, and the spec map must cover the same keys (the type enforces it). IteratingObject.entries(schemas)gives TypeScript a concreteZodTypeper entry undernoUncheckedIndexedAccess, which indexingschemas[id]did not.- Image bearing component tests mock
CorsImg(the pre existing convention in energy-rating and catalogue-cover), because Remotion's<Img>needs a live composition; the generated tests for spend-and-get, buys-cashback and product-description adopt the same mock. hiddenon a part with avisibility: hiddentest still asserts the copy is present in the markup, pinning "painted away, not removed" per part.
Phase 2 pixel gate (66 stills, 22 components x frames 74, 6, 50, vs out/baseline/)¶
- Frames 74 and 50: identical for all 22 (two first pass jitters, tiers f74 95 px and home appliance f50 4 px, both max channel delta 1, both identical on re render).
- Frame 6: identical for 15 components. Seven differ, every one inside the box of a part whose
old
fadeInwindow contained frame 6, and only in opacity (the slide offset formula is the same): catalogue-cover (cover 0..14, phone 6..20), energy-rating (badge 0..12), home-appliance-cashback (home 4..16), plain-text (text 0..12), product-description (logo 0..12, description 4..16, code 8..20), spend-and-get (logo 0..12). Cause: the sharedslide-fadelayer transition reaches full opacity at the half way point of the slide (clamp01(progress * 2)), the retired bespokefadeInfaded over the whole slide. Accepted as the price of the brief's shared transition vocabulary: the settled frame is identical and the mid entrance reads as the same motion with a slightly earlier fade.
Phase 3: Component Control¶
- Part fields reach the property panel as ordinary manifest fields. A component's part
declarations produce manifest bindings (
componentPartBindings): one per part prop whose schema node carries anrs.*control and whose value is present, at['parts', id, 'props', ...], grouped by the part's label. The generated catalogue template carries them, so a template component layer is edited through the existing bound field flow; an added component layer derives the same fields by running its bindings throughgenerateManifeston a one layer document. Rejected: hand built descriptors per part type (the existing added editors' pattern) — a part's props schema is a lockup shape, not a layer schema, and varies per component, so only the schema driven route reuses the controls faithfully. - Standard versus advanced maps onto the existing layout mode tiers. A standard field
binds at
basic, everything else atadvanced; the added component editor reads the same tier to decide what sits behind its opt in Advanced section, so the template flow and the added flow agree by construction. No new metadata axis. - Per part text colour is a dedicated control, not a manifest field. A part's text
typographyis a per ratio partial override, which resolves to no editable control in the manifest (a partial typography degrades to hidden). The editor renders a colour row for eachtypographyoverride named in the part's standard list, writing the colour onto every available ratio, the same "authored once, not per ratio" rulepatchRatioTypographyapplies. Bindings skiptypographyandpatternsfor that reason. hidden,transitionsandeffectsare always editable on an added part, through the same hand built controls the added layer animation editor uses, because a part that has none yet still needs a way to gain one; their bindings are set aside from the generated field set. On the disk template they bind only when present (a binding to an absent leaf fails publish).- An added component layer is a new variant of the added layer union with the
component-<uuid>id prefix, no bindings, opaqueprops/parts. Its bundled asset refs (partner logos, badges) are admitted at the API only when they appear in a registered component preset (presets-node, a second Remotion free node bundle besideprop-schemas-node): the preset is in repo and author published, the same trust as a template's own bundled refs. Rejected: allowing any bundled path undereffects/— a prefix rule admits paths no preset ships. - The component catalogue gains a brand
namespace(derived from its folder, pinned by a catalog test), so the RetailStudio add menu filters throughuseBrandEffectAccessexactly as the effect and transition pickers do; only components with a preset are offered. - Placement uses one shared default box (
componentDefaultBox, moved out of the Studio fixture into presets.ts) so the gallery and the editor place a component identically. A placed layer carries the catalogue label as its layerlabel, so the layer list names it. - Long headline check. Rendered the four seasonal banners with "This is the last Friday of
the month" in their first panel (
render-component --set): every panel widens to its copy and the rows below re centre with no overlap. Footy Finals fits the frame; the three 197px headline banners run past the 1920 canvas edge, which is the component hugging its content faithfully rather than a layout fault (the layer's own scale, or the base size, is the operator's lever). The markup level check (long-text.test.ts) pins that no text box is pinned to a pixel width and the root hugs its content.
Phase 4: Component Library¶
- Thumbnails come from the render-component harness, refactored into
component-harness.ts(the one layer scene, the font server, the brand font definitions) and shared byrender-componentand the newbuild-component-thumbnails, so a thumbnail is exactly what a harness still shows: the component at its default box on the gallery mid tone, at the settled frame, scaled withrenderStill({ scale })to 960 wide. 22 PNGs, about 1 MB, committed underapps/web/public/component-library/; the test checks existence only. - The page is capability gated only (
view:component-library), with no feature flag: the brief names the capability and nothing else, and the catalogue is code with no rollout to stage. The capability sits besideview:product-libraryinCAPABILITIES,CAPABILITY_LABELS, its own "Component Library" group inCAPABILITY_GROUPS, the webPERMISSION_FLAGS, and the seeded local role bundle (the seed's own comment asks for that). - Brand filtering reuses the brand matrix through brand names: the org's available brands
(the same TanStack query the Product Library page uses) map to namespaces with
namespacesForBrand, unioned, and the catalogue is filtered to those namespaces plus a preset. Only server data on the page is that brands query; the catalogue itself is static module data, so no new query or API surface. - Preview is a Radix dialog with the same thumbnail at dialog width and the part list (label plus a plain words type: Text box, Text, Image, Shape). No add, edit or delete.
- Adjacent fix, flagged: the sidebar's
navEntriesmemo already omittedcanViewProductLibraryfrom its dependency list (a stale nav after a permission change); addingcanViewComponentLibrarymeant editing that array, so the missing sibling was added in the same line rather than leaving a lint warning on the touched array.
Phase 5: self review¶
- Placed component props and parts are validated at the API patch boundary
(
invalidAddedComponentLayers): unknown component, props against the registry schema, each declared part present with the declared type and props against its schema, no undeclared part. The body boundary schema leavesprops/partsopaque by design (the registry lives above the schema package), so without this a malformed value would throw on a paid render. Same shape as the asset ref gate beside it; sameCAMPAIGN_ADDED_LAYERS_INVALIDcode. - Exports trimmed to what other packages consume:
boundValue(only its own test used it) removed;componentLayerSchemaForandCOMPONENT_PART_TYPESdropped from the schema package root (still reachable through thezodnamespace and their module). The add menu uses real list markup rather than list roles on buttons. - Verification (final run): compositions typecheck, lint, 985 tests, 10 templates valid, check-templates and check-registry clean; schema 674 tests; API typecheck and 2925 tests; web typecheck and 4913 tests; shared builds; root lint clean apart from pre existing aerender-engine max-len warnings. Catalogue composition rendered at fractions 0.08, 0.67 and 0.98 on three ratios.
Post-implementation verification and review pass (2026-09-11)¶
Full verification re-run at 56e56dcdd, all green: compositions typecheck/lint/985 tests/validate-templates/check-templates/check-registry, schema 674 tests, API typecheck (dev + build tsconfig) and 2931 tests, web typecheck and 4913 tests, root lint (only the two pre-existing api-v1 warnings). PR #1333 CI Checks green.
Review run via neo-dev-review against base 4cc79c0b1. Codex and Gemini CLIs are not installed on this machine, so the run degraded to the Claude pass plus the security-reviewer and db-perf-reviewer specialists and react-doctor. Reports in .reviews/Feature Neo 1984 Component Library V1/ (gitignored).
Findings acted on, in commit 7ec0decf6:
- buildComponentLayer aliased the module-level preset objects and one shared default box into the new layer (and into every ratio). No live bug, since every write goes through withValueAtPath, but latent. Now structuredCloned on the way out.
- Dropped five exports with no consumer in another file: THUMBNAIL_RATIO, THUMBNAIL_WIDTH, HARNESS_FPS, HARNESS_SCENE_FRAMES, componentThumbnailSrc.
Findings deliberately NOT acted on:
- No migration grants view:component-library to existing role groups, so members see nothing until an admin grants it. Left as is: 20260626120000_product_library.sql, the direct analogue, shipped the same way (no backfill). Who gets a new view capability is a product call, not a review fix. Flagged in the report and worth a line in the release notes.
- The security specialist's MEDIUM: the capability is a navigation gate only, and the thumbnails are world-readable under apps/web/public/. That is the stated design (the PR body says so), and the same artwork already ships in the compositions bundle to every user. Left as is; an explicit "navigation gate, not an authorisation boundary" note in capabilities.ts would be the cheap improvement if we want it.
- invalidAddedComponentLayers (API) duplicates the part membership rule in checkComponentParts (schema package). Real DRY violation, but factoring it out means a new cross-package export and touching a verified validation path; logged for a follow-up rather than folded into a review pass.
- Type-only exports (AddedComponentLayerIssue, PartPreset, ResolvedPreset, HarnessServer) kept: each names an exported function's parameter or return type.
- react-doctor's js-combine-iterations and async-await-in-loop hits: the loops are intentional (one headless Chromium at a time) and the chained iterations run over lists of tens of items.
Rebase onto main (2026-09-11, same session)¶
Discovered while checking CI after the review-pass commit: gh pr checks 1333 reported only Cloudflare Pages and the Socket checks, and the check-runs API showed CI Checks and Migration Conflicts last ran on 4aac8ee66. Cause: the PR was mergeable=CONFLICTING / mergeStateStatus=DIRTY, and GitHub does not dispatch a pull_request workflow for a PR whose merge commit it cannot compute. So phases 4 and 5 and the review fix had never been through CI. An earlier report in this session that CI was green on the latest head was wrong and has been corrected.
Conflict source: 26d26ed23 Disclaimers (#1305) on main touched the same two places this branch does.
- packages/shared/src/capabilities.ts labels map: kept both, view:component-library first, then the two disclaimer labels, then manage:templates. The CAPABILITIES array and CAPABILITY_GROUPS auto-merged.
- apps/api-v1/supabase/seeds/001-baseline.sql: one jsonb array literal; took main's list and inserted "view:component-library" after "manage:product-library", its original position.
Matthew chose rebase over a merge commit. Pre-rebase head 7ec0decf6 is kept on the local branch backup/pre-rebase-neo-1984-20260911T162156. Seven commits replayed onto ffd02b4ef; only af7516573 conflicted. New head dd7315fe9, force-pushed with lease.
Gotcha worth remembering: after rebasing onto a main that extended packages/retail-studio-schema, the web typecheck fails against the stale dist/*.d.ts in the worktree (TS2305 ... has no exported member 'isBrowserPlayableFormat'). pnpm install plus pnpm build in packages/shared, packages/retail-studio-schema and packages/retail-studio-compositions clears it. Not a code defect.
Full re-verification on the new base, all green: compositions typecheck/lint/986 tests/10 templates valid/check-templates/check-registry; schema typecheck + 674 tests; api typecheck (dev + build) + 3120 tests; web typecheck + 5056 tests; root lint clean apart from the pre-existing api-v1 no-throw-literal warning.
CI result after the rebase¶
First run on dd7315fe9 reported typecheck and preflight-checks as failures. Not a defect: the typecheck job log has zero error TS lines and ends with "The runner has received a shutdown signal" / "The operation was canceled", with the three ELIFECYCLE Command failed lines being Turborepo tasks torn down mid-flight. preflight-checks is only the aggregator that fails when any job does. Re-ran the two failed jobs; both passed.
Final state: all 15 checks green on dd7315fe9, including typecheck, lint, static-checks, test, test-web (both shards), web-build, api-docker-build, check-migration-versions and Cloudflare Pages. PR #1333 is mergeable=MERGEABLE, still a draft (mergeStateStatus=BLOCKED is the draft state, not a failing check).