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-boxandbetta-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 .pngis 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 |
|
| 02 | Boxing Day: Up To 50% Off | Boxing Day.psd |
|
| 03 | Red Hot Summer Sale | Red Hot Summer Sale .psd |
|
| 04 | Footy Finals: Bonus TV | Footy Finals .psd |
|
| 05 | Bonus Cashback v1: full stack, $300 | Bonus Cashback.psd |
|
| 06 | Bonus Cashback v2: no value | Bonus Cashback v2.psd |
|
| 07 | Bonus Cashback v3: $300, red flash | Bonus Cashback v3.psd |
|
| 08 | Bonus Cashback v4: $400, three tier | Bonus Cashback v4.psd |
|
| 09 | Bonus Cashback sticker: $100 | Bonus Cashback Sticker.psd |
|
| 10 | Home Appliance Cashback: category tab | Home Appliance Cashback.psd |
|
| 11 | Hisense Spend and Get: up to $500 Cashback | Spend and Get .psd |
|
| 12 | Betta Buys: plus $300 Cashback | Betta Buys.psd |
|
| 13 | 50% Off: Audio category | 50% Off.psd |
|
| 14 | Sale: Up To 30% Off | Sale % Off.psd |
|
| 15 | Price + Product: before / after cashback | Price + Product.psd |
|
| 16 | Price + Product v2: price and saving | Price + Product v2.psd |
|
| 17 | Product description and code | Product Description + logo.psd |
|
| 18 | Energy Rating badge: 4.5 stars | Energy Rating_.png (not the 0 byte one, see above) |
|
| 19 | Catalogue Out Now: flag | Flag.psd |
|
| 20 | Catalogue cover and mobile application | Catalogue.psd |
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.