Skip to content

Custom Component Catalogue: Design Brief

Stu's original prompt, reformatted (Notes to Slack code block wiped all styling), with the two marked sections filled in, the parts terminology threaded through wherever it was missing, a manifest built from the source docx and PSDs, and a working comparison harness worked out against what already exists in the repo. Every claim about existing code (schemas, scripts, registries, transitions) was checked directly against the source, not assumed.

Scope for this pass: build the components, nothing else. Component Creation and Component Manifest are the actual work, the standalone React components, their schemas, their presets, registered in COMPONENT_OPTIONS so TemplateBuilder can already pick them up (brand matrix needs no change, already covers Betta). Component Control, Component Parts, and Component Library describe the target shape these components are being built toward, and constrain how to build them (structure around part boundaries, use the // PART: comment convention, don't reuse existing components), but none of the wiring, screens, or schema work those three sections describe is being executed here. In particular, Component Parts is explicitly wave two, separate work after this pass. Component Control's manifest bindings and Component Library's whole screen depend on RetailStudio supporting component layers at all, which it currently doesn't (see the note at the top of Component Control), so neither is actionable in this pass regardless of sequencing preference. Stop at built, registered, and visually verified against source, don't reach for the rest.

Problem Statement

We need the ability to create a custom component catalogue on a client by client basis. The reason for this is because every client we onboard and work with could have bespoke components. What we mean by this is inside Retail Studio we have the concept of certain layer types like image or pointText or boxText that every client will likely use, but clients will also have very bespoke things we need to support such as maybe nikeDealSticker or adidasSaleImage, literally could be anything, and they're very much scoped to that individual client.

To support this we'll have to build these components custom for them and write the real React code and get that into the repo. The piece of work is all around how we can create these React components, store them correctly inside the repo with defaults, and then be able to drag and drop these components onto the Retail Studio canvas so they can be used properly inside their ad campaigns.

Database Architecture

For this piece of work we don't need to surface anything new in the database. Each of these components will be React code with hardcoded defaults for all the props, and in the document.json when trying to use this in Retail Studio, we'll have a layer type that points to this new custom component.

A component on disk is three files, not two. The existing convention is index.tsx plus schema.ts. This work adds a third, preset.ts, exporting a single default object shaped like z.infer<typeof theComponentSchema>, the exact props a freshly dragged instance starts with before anyone edits it. Worth being clear that this genuinely is new, not an existing pattern to copy: the registry entry type every current component uses (EffectEntry) has no defaults field of any kind, every existing component's real values today come from a human hand authoring them straight into a template's document.json, there's nothing already dragged from a blank canvas to look at. Defaults do not live in the React component or the schema file, since that would make them optional there, and optional defaults invite exactly the kind of quiet drift we don't want. The preset file is the single required source of default values, the component and schema stay clean.

For example we'll create a Black Friday sale component which might have several text areas, each one a named part with its own font style, font colour, background colour, and default value (see Component Parts below for exactly what a part can carry). Say the component has a part for the sale name and a part for the sale message, each with a default value living in the preset file. If the client ever wanted to duplicate the Black Friday sale to be a Christmas Day sale, this would be a full code change, or they can grab the Black Friday component, drag it on, and change the defaults to suit, then they'd also be happy. Illustrative numbers here, the real component this whole brief is built against turned out to have a different part breakdown once actually grounded in the source asset, three parts, not a word by word split, see Component Parts below for the real one.

The catalogue itself, meaning the full set of components, schemas, and presets, stays hardcoded the same way Blair originally scoped it. No new database table anywhere in this design.

Component Creation

I've attached some source content for what I want these components to look like. Analyse them, understand them, and then recreate them as their own standalone bespoke React component. Where the Component Manifest below flags a close match against something that already exists, that's reference for the shape and pattern only, not something to extend or wire up to directly, build this one as its own standalone component too, the same as every other row. All of these components and source materials are for the BSR group and the Betta brand, so ensure these React components are correctly placed in the repo, inside the correct directories inside the Retail Studio compositions package. We already have the concept of components, there are already some custom components in there (components/betta/betta-cashback-callout/, betta-discount-box/, betta-price-box/, and others), each its own folder holding index.tsx and schema.ts. Follow that exact convention for every new component in the manifest below, components/betta/betta-<slug>/, so this catalogue doesn't introduce a second layout style alongside the existing one.

One thing deliberately left open rather than decided here: whether these new components should render using the same shared low level primitives the existing ones do (PointTextBox, CroppedLine, FitRow, rasterizeTextNode, all under interpreter/layers/), or be fully self contained with no shared rendering dependencies. The tradeoff is real, a change to a shared primitive changes every component built on it, but that's also true of any shared code a template already depends on, so it may not be much of a special case. Not resolving that here, whoever builds these can make the call.

As part of building each component, identify its parts up front: the named, independently controllable sub elements it exposes (a text area, a background panel, an icon, whatever the source material actually contains). The formal parts mechanism (see Component Parts below) doesn't exist in the schema yet, that lands as its own piece of work right after this one, so there's nothing to declare it against today. Structure the React code around those part boundaries anyway (separate sub components or clearly separated JSX, not one flat blob), bind values to plain flat props for now, and mark each one with a comment directly above it:

// PART: title (boxText). Editable text, needs its own pulse toggle,
// no transitions beyond the shared entrance.

Include whatever that part will need once parts actually exist, its type, what should be independently controllable, any animation or effect it needs of its own. That comment is the handoff to whoever builds the parts mechanism next, so it should carry enough to convert straight into a parts entry without having to re examine the component or go back to the source material. Getting this boundary right matters more than getting it fast, since once something ships as a part it carries the full prop, animation, effect, and transition set, not a subset.

One more reuse check, smaller than the component one below but applies almost everywhere in the manifest: wipe and slide-fade already exist as registered shared transitions (transitions/shared/wipe/, transitions/shared/slide-fade/). "Panels and text wipe on" and "fade on and slide" show up in nearly every note in the manifest below, use these rather than authoring new transitions for the same thing. Pulse has no such shortcut, there's no pre built pulse preset anywhere, that one gets authored fresh using the existing animation schema.

When you've created the React component, isolate the output and compare it against the original source material. If it looks identical, great. If there are issues, revisit the design and figure out what went wrong before amending it. The end result should be as close to a pixel for pixel match as possible against the original source content.

One of the things that matters for that comparison is already solved, one still needs building.

Already solved: real brand fonts. The normal render pipeline (src/interpreter/fonts.ts in the compositions package) already loads the actual signed brand fonts through @remotion/fonts, gated so nothing renders before they're ready. That's the real render path, not the PSD interpreter's own headless fidelity harness, which deliberately skips fonts, so a comparison run this way sees genuine typography from the start, not a fallback.

Still needs building: rendering one component in isolation. Build this first, before working through the manifest below, everything after it depends on being able to actually run the comparison. The two render scripts that exist today, render-stills.ts and render-local.ts, both key off a registered template slug and render a whole scene. Neither renders a single component on its own, every aspect ratio still needs its own still, that part doesn't change. Building an isolated render is small though, not a new pipeline, a thin variant of render-stills.ts that wraps the one component being worked on in a minimal one layer scene instead of a resolved template, reusing the exact same bundle, select, render still call.

Reuse the existing comparison convention rather than inventing a new one. Templates already carry a layer whose id starts with reference, which overlays the original PSD at 50% opacity inside the render, hidden by default, kept with HIDE_REF=0. Carry that straight into the component harness. The reference image itself doesn't need a manual Photoshop export either, it can come straight out of the PSD's own composite, decoded through the interpreter, the same way the manifest thumbnails above were generated. There's no pixel diff tool anywhere in this repo today, and none is strictly needed, the existing convention is already a human looking at the overlay and judging it, an agent doing the same against the rendered still is a direct substitution, not a new capability.

One more registration step neither of us had mentioned yet: COMPONENT_OPTIONS in effects/catalog.ts is a hand maintained list, name, label, whether it supports motion blur, that the property panel reads to offer a component as a pickable dropdown option. A component with no entry here isn't selectable at all, and catalog.test.ts fails CI if this list drifts from the actual registry entries. Every new component needs an entry here alongside its three files, this isn't optional or automatic.

Also make sure whenever we create these components and write them into the repository, we update the brand matrix in code as well, since that specifies which brands can access which directories in the Retail Studio compositions package. The idea is that whenever we create all of these BSR components, we only want BSR users and organisations to be able to access them. Checked, and Betta already has its own namespace in brand-matrix.ts (Betta: ['shared', 'betta']), with the same list already covering effects, transitions, and a future component picker. These components just need to land inside the existing betta namespace directory, there's no new brand matrix entry to add.

Component Manifest

The actual build list, one entry per component, each one built and compared against its own source file per the process above. Source docx was ~/Downloads/Betta_Campaign_Storyboard_1.docx, source PSDs live under ~/Downloads/50% Off.psd and more/. Thumbnails below are the PSD's own composite, decoded straight out of the file through the same PSD interpreter this whole brief is built on, not a screenshot pulled from the docx. Still reference only, work from the actual PSD for anything pixel accurate.

Every row below is its own standalone component regardless of anything in this note, don't extend or wire up to an existing one, even a close match. What follows is purely a shortcut: the existing Betta component registry (components/betta/, seven components already there today: betta-cashback-callout, betta-cashback-blocks, betta-discount-box, betta-partner-bar, betta-payment-opt-bar, betta-price-box, betta-product-strip) already solved the layout shape for several of these rows once, and reading that solution is faster than re-deriving the same shape from the PSD alone. Use it as reference for the pattern, not as something to reuse:

  • 05, 07, and 08 (Bonus Cashback v1, v3, v4) against betta-cashback-callout, whose fields (prefix, upTo, amount, hero, footnote) line up row for row against all three thumbnails, the only differences between them are the amount value and the middle row's background colour, both already ordinary props on the existing schema, not a structural difference.
  • 13 and 14 (50% Off, Sale Up To 30% Off) against betta-discount-box, whose fields (prefix "UP TO", hero, suffix "OFF*") line up closely.
  • 15 and 16 (Price + Product, before/after and price+saving) against betta-price-box and betta-cashback-blocks, both of which already model a price stacked above a second value.

Checked two more directly. 06 (Bonus Cashback v2, no value) doesn't even share the shape of betta-cashback-callout despite being the same naming family as 05, 07, and 08, its thumbnail is only "Bonus" over "Cashback", no "Up To $amount" row at all, so this one isn't even reference worthy the way 05/07/08 are, it's its own simpler shape from the start. 09 (Bonus Cashback sticker) also does not match, a simpler two row layout with no separate hero row, genuinely new. Don't read either result as telling you anything about the other rows in this family, 05/07/08 match structurally and 06/09 don't, on the same schema, for two different reasons.

Checked three more. 12 (Betta Buys) looks like another likely match for betta-cashback-callout, "PLUS UP TO $300 / Cashback / on selected models" is the same three row shape on one background pill, which is exactly how that component's single background field already works. The "Betta Buys" wordmark above it is most likely its own logo layer in the scene, not part of this component, worth confirming rather than assuming. 11 (Hisense Spend and Get) does not match, its note explicitly says "change the logo" and betta-cashback-callout has no image field at all, text only, that's a structural gap a prop change can't close. 10 (Home Appliance)* can't be judged from the thumbnail either way, its own note says there's white on white text hidden in this composite that isn't visible here, don't guess at its shape from an incomplete render, check the real PSD directly.

None of the above is confirmed beyond that, it's a shape match from reading the schema and eyeballing the thumbnail, not a rendered pixel comparison, verify each one properly (using the harness above) before treating it as a reliable reference. Everything not named above, 01 through 04 and 17 through 20, simply hasn't been checked at all either. 01 through 04 (the seasonal banner group) look like they're probably a genuinely different family from the cashback and discount components above, none of the existing seven has anything like two independently pulsing colour panels, but that's a pattern guess from their notes, not something actually verified against the registry the way the rows above were.

One more thing to check before starting, rather than discover mid run:

  • The Energy Rating source folder has two PNGs, one of them is dead. Energy Rating .png is a 0 byte file, corrupted, ignore it entirely. Energy Rating_.png (60KB) is the real one, that's the one frame 18 points to.
# Component Source file Thumbnail
01 Black Friday: Sale On Now Betta Black Friday Theme.psd 01
02 Boxing Day: Up To 50% Off Boxing Day.psd 02
03 Red Hot Summer Sale Red Hot Summer Sale .psd 03
04 Footy Finals: Bonus TV Footy Finals .psd 04
05 Bonus Cashback v1: full stack, $300 Bonus Cashback.psd 05
06 Bonus Cashback v2: no value Bonus Cashback v2.psd 06
07 Bonus Cashback v3: $300, red flash Bonus Cashback v3.psd 07
08 Bonus Cashback v4: $400, three tier Bonus Cashback v4.psd 08
09 Bonus Cashback sticker: $100 Bonus Cashback Sticker.psd 09
10 Home Appliance Cashback: category tab Home Appliance Cashback.psd 10
11 Hisense Spend and Get: up to $500 Cashback Spend and Get .psd 11
12 Betta Buys: plus $300 Cashback Betta Buys.psd 12
13 50% Off: Audio category 50% Off.psd 13
14 Sale: Up To 30% Off Sale % Off.psd 14
15 Price + Product: before / after cashback Price + Product.psd 15
16 Price + Product v2: price and saving Price + Product v2.psd 16
17 Product description and code Product Description + logo.psd 17
18 Energy Rating badge: 4.5 stars Energy Rating_.png (not the 0 byte one, see above) 18
19 Catalogue Out Now: flag Flag.psd 19
20 Catalogue cover and mobile application Catalogue.psd 20

Notes per component, cleaned up from the docx:

  • 01. Ignore the yellow background in the reference. Editable text. Panels and text wipe on. "Sale On Now" fades on and slides into position. "Black Friday" and "Up To 50% Off" each need their own pulse toggle. Change colours.
  • 02. Ignore the red background in the reference. Editable text. Panels and text wipe on. Needs pulse controls, toggle per panel. Change colours.
  • 03. Editable text. Panels and text wipe on. Both the headline and the "Save Big" box need their own pulse toggle. Change colours.
  • 04. Editable text. Panels and text wipe on. Boxes and text need their own pulse toggle. Change colours.
  • 05 to 09. Same pattern as 04, across the five Bonus Cashback variants (full stack, no value, red flash, three tier, sticker). Editable text, panels and text wipe on, pulse toggle per panel, colours changeable.
  • 10. Same pattern as above, plus: there's white text sitting underneath that you can't see in the reference because the background there is also white, that text needs to fade on and slide down.
  • 11. Editable text, logo is changeable. Panels and text wipe on. Pulse toggle per panel. Colours changeable. The cashback figure fades on and slides down. The disclaimer fades on and slides down, separately.
  • 12. Editable text. Panels and text wipe on. Pulse toggle per panel. Colours changeable.
  • 13. Editable text. Panels and text wipe on. Pulse toggle per panel. Colours changeable. The "Audio" text fades on and slides down.
  • 14. Same general pattern as the rest of this group.
  • 15. Animation as per current templates. The price box stays a fixed length, don't shrink it if the top line of text happens to be shorter.
  • 16. Same fixed length rule as above. Navy price block reading "$1376" above a red "Save $223". A simpler two part module for a straight discount, no cashback maths. The "Save $223" panel sits on a white box that isn't visible in this reference slide.
  • 17. Includes a logo (not visible in this reference since the background is white), a product description, and a SKU. Build as per current templates.
  • 18. Can drag and add an effect to it, otherwise simple.
  • 19. Slides down from the top of screen. These sit in the top corner of the canvas.
  • 20. Images can be swapped. Worth checking whether it can slide in using an effect from the effects library.

Two more from the docx's general notes, no source file for either since they're plain text, not artwork:

  • A free form text box the user can type into directly.
  • A component that's just typed text, no background box behind it at all.

Component Control

"The same way we already change fields inside a pointText layer" describes the target, not the current state, worth being concrete about the gap between them since this section is the one that has to close it. Two editors exist: TemplateBuilder (internal, engineers author templates in it) already has a working "add a component layer" flow (AddLayerMenu.tsx, driven by COMPONENT_OPTIONS). RetailStudio (the customer facing campaign editor) doesn't, its property panel (AddedLayerEditor.tsx) currently renders nothing for a component type layer, confirmed by an existing test asserting exactly that, and its layer panel has no add-layer UI at all yet either. Every component layer in a real campaign today got there by an engineer baking it into that template's document.json directly. That's the starting point this section's manifest bindings work has to build from, not a surprise, just worth having the concrete file names and current behaviour on hand rather than a vague "make it editable."

When we build the custom components, we want to ensure all the elements are responsive. For example if we change "Black", which has a black background text box, to now say "This is the last Friday of the month", we've just added a load of words, therefore widening the text box significantly, and we need to ensure that every part in the entire component responds correctly and scales accordingly. Good thing is it's its own React component, so it should have full control.

Things we would want to allow them to change and control inside Retail Studio, per part:

  • [ ] Text value
  • [ ] Text colour
  • [ ] Background colour
  • [ ] Rotation
  • [ ] Effects
  • [ ] Basically everything except position
  • [ ] Anything else quite simple you think we should surface to the client, use your own logic

How we set these up in the template JSON, a load of bespoke keys inside the layer's props object, flat for this first wave per Component Creation above, becoming part scoped once Component Parts below actually lands, and then create the manifest bindings so we can actually change the values being used from the right hand property panel, the same way we already change fields inside a pointText layer or an image layer, these new components are just very simple layers in the JSON. It's a layer in the JSON that has a unique type which points to a real React component in the repo, and likely has a load of props, defaulted in code, that we can then change.

Component Parts

Renamed from "Custom Effects/Transitions". Every animation, transition, and effect in the schema is already custom, so that name didn't describe what's actually new. What's new is that a component can expose named, independently addressable sub elements, and each one carries its own props, animations, effects, and transitions rather than sharing one set at the whole component level. That's a part.

This is wave two, built once the components above exist and their // PART: comments have real data to work from, not a prerequisite for Component Creation. A few things worth being precise about, since this is the piece of new schema the rest of this brief eventually depends on:

Parts are fixed per component, declared by whoever builds it. A part is never invented by an end user at runtime, it's authored once, in code, the same way a component's props are.

A part is effectively a mini layer, scoped inside a component. Tag it with the same type discriminant real layers already use, boxText, pointText, image, shape, and so on. A text part is a boxText or pointText layer living inside a component instead of inside a scene. This means the inspector's existing per layer type field rendering already knows how to render a part's fields, it just needs pointing at the part's data.

Every part carries the full set: props, animations, effects, and transitions. Not a subset. Concretely: the Betta "Black Friday: Sale On Now" storyboard frame (component 01 above) needs exactly this.

Part Needs independently
"Black Friday" panel Colour, pulse on or off
"Up To 50% Off" panel Colour, pulse on or off, separate from the panel above
"Sale On Now" text Editable text, its own fade and slide entrance, no pulse at all

The brief for that frame explicitly asks to turn the animation on or off per panel. If animation lived at the whole component level, both panels would have to pulse together or not at all. There's no way to give the user independent control over one without parts.

Schema shape, additive on top of the existing component layer schema, nothing else changes:

componentLayerSchema {
  ...
  parts?: Record<partId, {
    props?: ...
    animations?: AnimationsSchema
    effects?: EffectsSchema
    transitions?: TransitionsSchema
  }>
}

This is additive. Nothing about an existing layer, template, or manifest binding needs to change to add it, only a newly built component ever touches parts.

One geometry problem worth flagging now, not later. When two parts need to pulse from one shared point (the visual centre of the whole component) rather than each part's own centre, like the Black Friday example above, that's the standard scale around an external pivot problem, set each part's transform origin to the component's centre expressed as an offset from that part's own top left corner. This stays entirely inside a component's own render code. The parts schema above doesn't need to know pivots exist at all.

Once parts exist, everything in the Component Control checklist above applies per part, not per component. A component author also decides which of a part's fields are standard (shown in the main property panel) versus advanced (tucked into an opt in section), reusing the existing per layer type field rendering rather than inventing new panel machinery.

Worth being explicit about one thing this section does not need to solve. rsts-changes-for-stu.md proposes grouping and pins, layers that move together, a layer pinned to another layer's edge, for independently positioned manifest layers inside a scene. A part is never independently positioned, position is the one thing explicitly excluded from what a part exposes, so there's nothing for pins to attach to here. Reflow between parts (the Black Friday panels staying aligned as they resize) is the component's own React code's problem to solve, the same way any other React component handles its own internal layout. Pins and parts don't overlap, they solve different problems.

Component Library

Now that we've got all of our components pushed and actually in the codebase as React components, we need to surface a component library to the client so that they can see it. This component library is going to be view only, so we need to surface a view component library permission for the user groups. That's a real, existing mechanism, not something to invent, packages/shared/src/capabilities.ts already has a view:product-library / manage:product-library pair for exactly this shape, adding view:component-library alongside it (to CAPABILITIES and CAPABILITY_LABELS, both hand maintained per that file's own comment) is the same small change, no manage: counterpart needed since this never gets one.

Whenever you land on this screen it's just going to be a mosaic grid of thumbnails. It's going to look at the custom components, generate some sort of thumbnail, and show it to the user. The component library is view only, there is no ability to add a new component, edit a component, or delete a component. The reason for this is that add, edit, and delete would actually be fundamental code changes, which a client obviously can't do. This is just a view only resource for them.

Worth being precise about what "view only" means here, since it's read two different ways. It means a client can't add, edit, or delete a component TYPE in the catalogue, that's the code change Stu's talking about. It says nothing about whether they can place an INSTANCE of an existing cataloged component onto their own campaign, which is the actual "drag and drop" goal from the Problem Statement, and which is currently blocked for a completely different reason, see the note at the top of Component Control. Whether this screen is meant to be the actual drag source into RetailStudio, or purely a browse and preview surface with placement happening some other way, isn't decided here either.