Skip to content

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 #cfd8e3 in packages/retail-studio-compositions/out/baseline/<name>-f<frame>.png, rendered through pnpm render-component unchanged. 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 every templates/*/document.json: only betta-component-library (regenerated from the presets) references any of the 22 new components; betta-red-hot-* reference the pre existing betta-price-box / betta-payment-opt-bar only. So requiring a migrated component's declared parts to be present cannot break a published document.

Phase 1: the parts schema

  • Part type is an enum, not a discriminated union. type: z.enum(['boxText', 'pointText', 'image', 'shape']) on one part object. Alternative: a z.discriminatedUnion('type', ...) with one member per type. Rejected: at the schema package level a part's props are opaque (same as component props), 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.
  • hidden is a part level field, not a part prop. hidden: rs.boolean() sits beside props/animations/effects/transitions on 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 shape partStyleSchema already uses for a hidden text span.
  • Part schemas are resolved through a second injected resolver, componentParts(name) => ComponentPartDefinition[], next to the existing componentSchema(name). ComponentPartDefinition = { id, type, schema }. Alternative: fold parts into the component props schema resolver's return value. Rejected: componentSchema is consumed by five walkers as "the ZodType for props"; changing its return shape would touch every caller and the web app's componentPropSchema export 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): componentLayerSchema with props swapped for the component's real schema and parts swapped for a z.object keyed by the declared part ids, each with its props swapped for the part's schema. The five schema walkers (resolveAspectRatio, collectPerRatioBlocks, withCanvasRatios, generateManifest, validateTemplate) all descend that one schema, so parts.<id>.props.<...> bindings, per ratio blocks inside part props, and control declarations resolve exactly like props.<...> does today. Rejected: special casing parts inside each walker's record branch (five copies of the same rule).
  • Validation of parts (with the resolver supplied): an undeclared part id, a type that differs from the declaration, and a declared part that is absent are all rejected. Absent is included because the render host reads layer.parts[id] and a missing part would throw at render (a paid Lambda) rather than at publish. Without the resolver the check is skipped, mirroring knownComponentNames.
  • Part transition and effect windows are component local. delay, startFrame, durationInFrames on a part's refs count from the component's own frame 0, the same basis EffectComponentProps.frame and the old wipeIn.delay used. 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 wide pulse timing at the top level. The handoff suggested "a looping return style animation". Checked returnAnimationSchema: 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.
  • wipeIn becomes transitions.in = { type: 'remotion', name: 'wipe', ... } and fadeIn becomes transitions.in = { type: 'remotion', name: 'slide-fade', ... }, both registered shared layer transitions. Pixel parity: the wipe is identical (same easingCurve, same wipeClipPath, 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 bespoke fadeIn faded 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) and rotation stay 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. offset is advanced; rotation is standard (the brief's checklist names rotation).
  • A component declares its parts in schema.ts (Remotion free) as export const <name>Parts: ComponentPartDeclaration[] with { id, type, label, schema, standard } where standard lists the prop paths shown by default (the rest are advanced). index.tsx references the same array on EffectEntry.parts; prop-schemas.ts aggregates them into COMPONENT_PARTS for 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 with typography mixed in. Every preset consumer (render-component, the Studio fixture, the test kit, the disk template builder) reads the three keys explicitly instead of destructuring typography out of a flat bag.
  • EffectComponentProps gains parts and fps. parts is the resolved layer.parts (flat per ratio). fps is needed because a part transition with easing: 'spring' runs a real Remotion spring, and the test kit renders outside a Remotion composition where useVideoConfig() throws (verified). Threaded as a prop rather than read from a hook for that reason.
  • ContentEffects split into a hook free ContentEffectsAt({ frame }) plus the existing ContentEffects that reads useCurrentFrame(). A part's effects render through ContentEffectsAt with 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 what ContentEffects resolves against, mirroring layer-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 in prop-schemas.ts is hand maintained because its source (the EffectEntry) is Remotion bound; here the source (schema.ts) is already Remotion free, so the one liner over componentPartsOf(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 with git 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-tiers frame 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.
  • declareParts iterates 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). Iterating Object.entries(schemas) gives TypeScript a concrete ZodType per entry under noUncheckedIndexedAccess, which indexing schemas[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.
  • hidden on a part with a visibility: hidden test 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 fadeIn window 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 shared slide-fade layer transition reaches full opacity at the half way point of the slide (clamp01(progress * 2)), the retired bespoke fadeIn faded 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 an rs.* 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 through generateManifest on 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 at advanced; 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 typography is 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 each typography override named in the part's standard list, writing the colour onto every available ratio, the same "authored once, not per ratio" rule patchRatioTypography applies. Bindings skip typography and patterns for that reason.
  • hidden, transitions and effects are 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, opaque props/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 beside prop-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 under effects/ — 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 through useBrandEffectAccess exactly 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 layer label, 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 by render-component and the new build-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 with renderStill({ scale }) to 960 wide. 22 PNGs, about 1 MB, committed under apps/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 beside view:product-library in CAPABILITIES, CAPABILITY_LABELS, its own "Component Library" group in CAPABILITY_GROUPS, the web PERMISSION_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 navEntries memo already omitted canViewProductLibrary from its dependency list (a stale nav after a permission change); adding canViewComponentLibrary meant 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 leaves props/parts opaque 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; same CAMPAIGN_ADDED_LAYERS_INVALID code.
  • Exports trimmed to what other packages consume: boundValue (only its own test used it) removed; componentLayerSchemaFor and COMPONENT_PART_TYPES dropped from the schema package root (still reachable through the zod namespace 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).