Skip to content

Design decisions

Why this is shaped the way it is, what was rejected, and what is deliberately not built — including the bugs that shaped the design.

For the JSON itself see the config reference; for the terse rules see CLAUDE.md.


1. Structure

One monorepo, four packages

packages/schema was not in the original three-part brief. It exists because the producer of the config (the editor) and its consumer (the visualizer) must never disagree about the format. The alternative — a copy of the parser in each — is what the neighbouring DripFitLab project does, and it maintains two files "byte-identical by hand". That instruction in a contract document is the smell that argued for a shared package here.

Everything derived from the config lives there and is pure: parseConfig, resolve (rules), renderPlan (compositing plan), toCartSummary (cart properties). The visualizer only turns a plan into pixels; the theme only mirrors properties into inputs. That is why most of the test suite can run without a DOM.

npm workspaces, not pnpm

Every other repo on this machine uses package-lock.json; there was no pnpm lockfile anywhere and pnpm was not installed. Reversible in one command.

Lit for the widget, React for the editor

Deliberately split rather than unified. The storefront bundle has a hard size budget, where Lit's ~6KB buys reactive properties and declarative templating. The editor has no such constraint, and React's ecosystem wins there.


2. Rendering

Runtime tinting, not pre-rendered assets

Layer art is a grayscale mask recoloured at draw time. One asset serves any number of colours, and adding a colour is a data change, not an art request.

shade and light are separate layer roles

The obvious design is one grayscale shading map multiplied over the tint. It looks wrong. Canvas composites in non-linear sRGB, so multiply darkens far harder than real light does — a map authored around 50% grey turns a vivid red into brick.

The fix is threefold and all three parts matter: shadow multiplies, highlight screens, and the shade map is authored white-biased — near-white everywhere the surface is lit, dark only in genuine shadow. opacity on the shade layer is the merchant-facing dial when it is still too heavy.

Verified rather than assumed: sampling an exported PNG, the shirt centre reads rgb(180,85,63) against a specified tint of #b4553f — exactly the colour asked for, undarkened.

All compositing happens in a scratch canvas

globalCompositeOperation is destructive across the entire destination canvas, not just where you draw. source-in erases everything outside the source's alpha, so tinting a mask directly on the stage wipes every layer already painted. ctx.save()/clip() is not a substitute — clip constrains the draw region, it does not stop source-in clearing outside it.

One scratch canvas is reused for the element's whole life. Allocation is expensive (particularly in Safari) and one per layer multiplies memory by the layer count.

multiply and screen also paint the full rect, so a destination-in re-clip runs after the overlays or the shading bleeds across everything behind the product.

Two-tier image loading

On-screen rasters come from Shopify's ?width= transform, sized to what the element actually occupies. Full resolution is fetched only for export.

This is a memory decision, not a bandwidth one. A 2000×2400 RGBA bitmap is ~19MB decoded; a dozen layers is ~230MB resident, which is an out-of-memory tab crash on iOS Safari rather than a slowdown.

Everything scales by fractions

Every coordinate in the schema is a fraction of the stage — rect, text size, arc radius. The canvas backing store is derived as h = w × stage.height / stage.width, and the host reserves aspect-ratio from the config before any image loads, so there is no layout shift.

Measured across a 3.5× width range (280px → 1000px), the rendered text box stayed within 1% of the same fractional position and the aspect held at 1200/1400.


3. Text and engraving

Text groups reuse the selection map

A text group's value is the typed string, stored in the same Record<groupId, value> as option ids. That is what lets rules, cart properties and the payload work on it with no extra plumbing — and it gives the rule this feature actually needs for free:

"showIf": [{ "group": "engraving", "notIn": [""] }]   // only once something is engraved

Fonts are awaited before the first paint

fillText does not wait for a font. If the face is not ready the browser substitutes a fallback with different metrics and draws that — auto-fit then sizes against the wrong glyphs, and an export taken from that frame is not what the customer approved. Only the families a render actually uses are fetched.

A font that fails to load degrades to a fallback and logs loudly that the export will not match. It does not take the product page down.

Sanitisation runs on every resolve, not on input

A value arriving from a URL, a restored _config payload or an API call gets the same treatment as something typed. Enforcing the allow-list only in the input handler would mean enforcing it only where a human happens to be typing.

Curved text: radius is a fraction of width on both axes

Scaling per-axis would make the path an ellipse on any non-square stage, and glyphs around an ellipse need variable rotation to stay tangent. Anchoring to one dimension keeps it a true circle at every screen size, because the canvas always preserves the stage's aspect exactly.

Auto-fit for an arc is angular — bounded by maxSweep, not by a box, because that is what actually constrains a curved layout.

Kerning is lost on an arc: there is no canvas primitive for type on a path, so each glyph is placed individually and fillText can only kern within one call. Increase letterSpacing rather than fighting it.

Text finishes are Canvas 2D, not a library

Emboss, carved, embroidery and the metallics are offset glyph copies plus a gradient fill — roughly 1.5KB. The libraries that own this space were each rejected for a specific reason, not on taste:

pixi-filters The real answer for BevelFilter/EmbossFilter, but it means adopting PixiJS and WebGL — larger than this entire widget, and it would replace Compositor.
Fabric.js / Konva Scene graphs that duplicate a renderer that already exists. Their text effects are gradients and shadow anyway, which come free.
SVG filters feSpecularLighting is the classic bevel, but ctx.filter = url(#…) is the flakiest corner of ctx.filter, and SVG-as-an-image cannot load external fonts — fatal here, since the whole font pipeline exists to make exports match.

Carved (deboss) is emboss with the two passes swapped. That swap is the entire difference between type standing proud of a surface and type cut into it, and for an engraving the carved reading is the physically honest one.

Chrome and gold are gradients, not a rendering mode. The characteristic look comes from a sharp dark→light transition at the "horizon" — the reflected boundary between sky and ground. Without it a grey gradient just reads as grey type. That value is not discoverable by dragging sliders, which is why the editor ships presets.

Embroidery is shaded within its own colour. A white highlight and a black shadow read as moulded plastic; thread catching the light stays blue-on-navy. The editor's preset derives every tone from the text colour for that reason.

Text colour is an effect, and its tones are derived

tint applies only to mask and decal layers — everywhere else it would be recolouring finished artwork — so it can never recolour type. Before setTextStyle, a shopper-selectable thread colour meant one whole text layer per colour, toggled by setVisible, with every layer having to agree on the arc, the box, the font and the relief. Disagreeing by a hair was invisible in the editor and obvious on the product.

The effect alone is not enough, and this is the part worth keeping:

Relief tones are derived from the body colour by default. highlight and shadow omitted now means derive, not white-and-black. Two reasons, and the second is the load-bearing one:

  • White-and-black relief reads as moulded plastic. Thread and engraved metal are shaded within their own colour.
  • No author can pin tones for a colour chosen at runtime. Without derivation, picking gold would keep whatever navy highlight the author last saved, and the type would look lit from a different scene than the one it is in.

So the precedence is effect → layer → derived, and an option needs exactly one hex value. The embroidery example went from three text layers to one.

Deriving correctly is where the traps are, and they are why shade() lives in @pc/schema rather than the editor: the editor shows tones in its pickers and the renderer computes them for colours no author ever saw, and two implementations would drift. Mixing toward white desaturates (navy → grey); scaling the channels clips them one at a time and swings the hue (gold → #ffff71). It scales only as far as the brightest channel allows, then applies the remainder as a mix toward white.

Font and size travel on the same effect, rather than a separate setFont. They are the same kind of change — an option restyling one text layer — and splitting them would mean two effects to express "serif, a bit smaller", with an ordering question between them for free.

The metrics problem that made this look risky is handled by the pieces that already existed: auto-fit re-runs against the new glyphs every render, minSize stays out of reach of effects so an option cannot produce unengraveable type, and planFonts reads the plan rather than the config, so a family an option introduces is loaded and awaited before the paint like any other.

A gradient fill is left alone. Chrome and gold are designed ramps with a sharp horizon; a single swatch colour cannot meaningfully rewrite one, and trying would turn "pick a colour" into "destroy the finish". Solid fills follow the colour, which is the case a swatch is actually for.

Fonts: files and stylesheets are different mechanisms

A Google Fonts URL is a stylesheet, not a font — FontFace expects binary and cannot load it. Stylesheets get a <link> in document.head instead, then document.fonts.load() confirms the family.

Three details that are each load-bearing:

  • The link goes in the document head, not the shadow root. @font-face inside a shadow tree does not register with the document's font set, and canvas fillText resolves families against the document.
  • The confirmation must come after the sheet parses. Asked earlier it resolves to an empty list, which is indistinguishable from "family not found".
  • The family name is read out of the URL rather than retyped, because it has to match the stylesheet exactly and a near-miss renders a fallback silently.

Hotlinking Google sends every shopper's IP to Google on every product page, which German courts have ruled a GDPR breach without consent. Both the parser and the editor say so. Fonts do not taint the canvas, so self-hosting is a privacy and reliability decision, not an export one.


4. Shopify integration

Line item properties, not variants

Avoids variant explosion for combinatorial options, at the cost of one real constraint: properties cannot change the price. There is no property, no attribute and no theme-side trick that changes it.

So with pricing.mode: "none" the component renders no price UI at all. An earlier plan had it displaying priceDelta; that is wrong, because showing "+$35" that checkout does not charge is a chargeback and a Shopify TOS problem. variant and function modes are scaffolded for when options must really cost money.

A theme block, not a theme app extension

An app extension needs a Partner app, OAuth, hosting and a distribution story. The Liquid is ~95% identical either way, so building the plain block first cost almost nothing against an eventual migration and removed all of that from the critical path.

A CDN release exists, and the theme asset is still the default

Immutable versioned hosting was originally left out on the grounds that it only matters when serving many stores. That was right about the reason and wrong about the conclusion: the committed asset is the right default, but it is a poor only option. A merchant on a theme they cannot edit freely, or one who wants the same bundle across several stores, has no path that does not involve copying a file per store per upgrade.

So both exist, and the split of responsibility is deliberate:

  • theme/assets/ stays the default and stays gated for staleness in CI. Copying the file in means a product page with no third-party runtime dependency — worth more than saving one file copy.
  • cdn.simple-configurator.garusin.com/v/<version>/… is for merchants who would rather pin a URL. Published only from a v* tag, and the release refuses to overwrite a version that already exists.

There is no latest and no floating major alias. A mutable URL means a merchant's product page can change behaviour with nobody deploying anything, which is precisely the failure the committed asset avoids. Pinning a version and choosing when to move is the entire value on offer here; an alias would hand it back.

The bundle is committed into theme/assets

theme/ has to be a self-contained set of files a merchant copies in, with no build step on their side. CI fails if the committed copy has drifted from the build — the files a merchant installs must be the files that were tested.

Block, section, snippet — three ways in, in preference order

They are not alternatives so much as a fallback chain, because Shopify themes in the wild span about a decade of conventions.

Block (preferred) Sits inside the theme's product section, so it can go in the media column or beside Add to cart. That placement is what makes the split layout possible. Requires theme-block support.
Section A sibling of the theme's product section, so it can only stack above or below it. The split is therefore unavailable and it renders preview and controls together. For themes with no block support.
Snippet Direct {% render %} from a template. Last resort, and the only route that requires editing theme code.

The snippets emit markup only — the block and the section are what load the two <script> tags. That split is deliberate (a page needs the assets once, not once per instance) but it made the documented snippet fallback render an inert element, because the docs never said to load them. check-theme.mjs now fails if anything renders the configurator without loading the engine.

A template may only name things we own

templates/product.configurator.json originally nested our two blocks inside the theme's own product section, which meant naming main-product, title, price, variant_picker, quantity_selector and buy_buttons. Those are Dawn's names. On any other theme the import fails, reporting whichever it hit first rather than all of them — so the file was useless precisely where a merchant most needed a worked example, and we shipped it in a list of files that otherwise always work.

The fix was not better documentation. It was to remove the dependency: a self-contained product-configurator-split section renders the canvas, the options, the title, the price and a real product form, and the template references only that. Nothing in the file belongs to anyone else, so there is nothing to guess wrong.

The cost is honest and stated in the install doc — the page inherits none of the theme's product styling or features, because it is not using the theme's product section. A merchant who wants a themed page uses the two blocks in the theme editor, which was always the better route and now leads.

A template that names another author's sections cannot be verified by us and cannot be relied on by them. That is the general form; it is why check-theme.mjs reports host-theme block types as unchecked rather than pretending to validate them, and why the shipped template now has none.

theme/ needs its own checker

It has no build step, no type system and no tests, and both failure modes are silent on the merchant's side: a malformed {% schema %} makes a section vanish from the theme editor, and a template naming a setting that no longer exists imports with that setting reset to its default. Neither produces an error the merchant can act on; both read as "you configured it wrong".

npm run check:theme parses every schema, verifies templates only set settings that exist, checks block_order against the defined blocks, and enforces the asset-loading rule above. It deliberately does not validate blocks the host theme provides — their names vary per theme, so guessing would fail on every theme but Dawn.

Two elements, not one

<product-configurator> renders the product; <product-configurator-options for="#id"> renders the controls. They exist as a pair because the preview belongs in the media column and the swatches beside Add to cart, and on every Shopify theme those are different DOM subtrees — not siblings you can wrap in one element. A ui attribute alone cannot solve that.

The options element holds no state. It reads the configurator's settled selection and calls back into it, so there is one source of truth regardless of how many are on the page. Both render from one shared controls.ts, so a fix to swatch keyboard behaviour cannot land in one and not the other, and ::part() names stay identical whichever you style.

Three ordering hazards are handled, because a theme controls neither script order nor render order: the options element may be rendered before its target (a MutationObserver waits), the script may load before the element upgrades (customElements.whenDefined), and a selector may never match (it warns rather than failing silently). register(prefix) renames both tags together so the pair cannot drift onto different generations.

The inbound event is a different name from the outbound one

The element emits configurator:change and listens for configurator:set. Reusing one name would mean listening for the event you also emit — an infinite loop waiting to happen.

configurator:set exists so a theme can drive the preview from markup it already renders, with no element reference and no import. Three rules make it safe to expose publicly:

  • An empty merge does nothing, non-string values are ignored rather than coerced, and a non-object detail is ignored. A stray event from something else on the page must not clear a shopper's selection. Only an explicit replace: true resets.
  • Events arriving before hydration are replayed, not dropped. A lazily hydrated configurator driven by a theme's controls is precisely the case this API exists for.
  • No double application. An event fired at an element also bubbles to document, so the document handler ignores anything whose target is not document itself.

5. The editor

The preview is the real component

Preview.tsx imports @pc/visualizer from source and mounts the actual element. A second renderer would drift from production the moment either was touched, and the whole point of a preview is that the merchant signs off on what shoppers will see.

Konva draws editor chrome only

Konva earns its place for interaction — hit-testing and the Transformer's resize handles are genuinely fiddly to hand-roll, and in the editor bundle size is free. It renders a transparent overlay of outlines, handles and an arc guide on top of the real canvas and writes results back as fractions.

It must never reach packages/visualizer: ~45KB gzip against a 30KB storefront budget, and it would duplicate a renderer that already exists.

Editing is permissive, publishing is strict

A half-built document must stay editable. An empty layer src is a warning, not a parse error; the editor gates export on it separately, the same way it handles blob: URLs. Making it fatal meant clicking "add layer" instantly invalidated the document, froze the preview and disabled export.

Presets over sliders, and examples over empty fields

The editor exposes the raw relief and gradient fields, but leads with presets — Flat, Carved, Emboss, Embroidery, Chrome, Gold, Rose. A convincing chrome needs a specific sharp value transition partway down the gradient, and no one finds that by dragging four sliders. The presets are the encoded knowledge; the fields are the escape hatch. Each preset sets every field it can touch, including the ones it wants off — the alternative is a stroke from one preset surviving into another, which also breaks detecting which preset is active.

Every input carries a role-aware example rather than an empty box or a generic placeholder: a mask layer's src suggests a grayscale filename, a shade layer's suggests a shading map. The config is a fairly deep nested document, and the shape of a correct value is much easier to show than to describe.


6. Bugs found by verification

Each of these was found by exercising the thing in a browser, not by reading the code. They are the reason the harnesses exist.

Bug Why it mattered
form.id returns an <input> A <form> exposes named controls as properties, and every Shopify product form has <input name="id"> for the variant. This produced form="[object HTMLInputElement]" on every hidden input, so no property reached the cart. Would have hit every store.
Async guard dropped work #init() awaits; a .config assignment landing mid-flight hit the busy guard and was discarded, leaving the element permanently blank. Guards must reschedule, not drop.
resolve() discarded valid selections A group whose dependency had not defaulted yet on the first pass lost the caller's choice — so a deep link or restored cart payload silently fell back to defaults.
Lit's @customElement is not idempotent It defines unconditionally at module evaluation and throws on the second. That aborted every hot reload and wedged the entire editor — all buttons stopped working. On a storefront it fires whenever the bundle loads twice (theme include plus app block).
Empty src was fatal Adding a layer instantly invalidated the document. See "editing is permissive" above.
Arc centre was clamped to 0–1 A varsity arc's circle centre sits below the garment, usually off-canvas, with a radius larger than the stage. The clamp made the commonest curved layout impossible.
Inside-arc text landed on the wrong side Making the radial step depend on direction put bottom-of-circle text at the top. Caught by a test that computes each glyph's position from the transform matrix.
Konva fills closed shapes on the hit canvas Even with no fill. The arc guide swallowed clicks across its whole disc — at a typical radius, most of the product. Needs a hitFunc that strokes.
ctx.lineWidth in a hitFunc does nothing strokeShape re-reads the width from the shape, so the grab band stayed 1.5px. Widening it needs hitStrokeWidth.
--pc-muted was hardcoded black Invisible on a dark storefront. Secondary text now uses currentColor at reduced opacity so it adapts to whatever theme hosts it.
document.fonts.check() lied It returned true for a family that had never loaded, with document.fonts empty. It is not a usable availability test. Comparing measureText widths is — that is how the Google Fonts path was verified (790px against the fallback's 769px).
Lightening a colour ruined it, twice The embroidery preset derives its tones from the thread colour. Mixing toward white turned navy into grey — dusty plastic, not yarn. Scaling the channels instead clipped gold to #ffff71: red and green pinned at 255 while blue lagged, swinging the hue. It now scales only as far as the brightest channel allows, then mixes the remainder.
Presets left a stroke behind Embroidery sets an outline and the others did not clear one, so switching to Flat kept it and preset detection showed the wrong selection. Every preset now owns every field it can set.

Two harnesses exist because of these:

  • theme/dev/product-page.html mimics a real OS 2.0 product page and loads the built artifact, serializing the form exactly as an AJAX cart does (new FormData(form)). It caught the form.id bug.
  • A transform-matrix-tracking fake canvas in packages/visualizer/test/text-render.test.ts computes each glyph's final position, so curved-text geometry is checked against the circle it should sit on rather than asserted loosely.

7. Deliberately not built

Why
Multi-line / wrapping text Single-line auto-fit covers plates, tags and most prints. Wrapping needs line-breaking, a line count and a different fit model.
A stitch texture for embroidery Thread is currently relief plus a tonal gradient. Visible stitch direction would need a tiling texture masked to the glyphs, or per-stitch geometry.
Multi-view (front/back) Additive later: an optional view field on layers plus a top-level views[], with parseConfig normalizing a flat document into a single default view. Building it now roughly doubles the editor's surface for a feature not yet asked for.
Uploaded cart preview images Embedding fails the requirement, not just the size budget: most email clients, Gmail included, strip data: image URIs, so a base64 thumbnail is broken in the confirmation email that was the reason to want it. Measured on a cap: 860 KB full PNG, 4.3 KB for a 200px JPEG data URI. A real upload needs a public write endpoint, retention and privacy policy — the first piece of this project with operational weight. The cart page re-renders from the ~120-byte _config payload instead.
Theme app extension Needs a Partner app and a distribution story. The Liquid is nearly identical, so migrating later is cheap.
Rive as the render engine ~398 KB gzip for the smallest runtime, against a 30 KB budget — see below.

Rive, measured

Rive is a real-time vector animation runtime with state machines and runtime data binding — genuinely better than this at animation, and worth re-checking if the product ever becomes animation-led. It was rejected on three counts, in descending order of how decisive they are.

Size. @rive-app/canvas-lite, the smallest build, ships rive.wasm at 312.7 KB gzip and rive.js at 85.2 KB — about 398 KB, against a 30 KB budget that took a recorded justification to raise from 28. There is no version of that argument that ends at 400 KB on a shopper's phone.

Cost. The runtimes are open source, but Rive's pricing is "free to create, $9/mo to ship" — publishing a .riv for production needs a paid seat. This project currently has no recurring cost beyond AWS.

The authoring model is the wrong shape, and this would sink it even if the bytes were free. Today a merchant authors JSON over their own photographs, and the artefact is a few KB in a product metafield. With Rive a designer authors vectors, and the artefact is a binary .riv that cannot live in a 65,535-byte metafield — so every product needs binary hosting, which the committed-asset design exists to avoid. Adding a colour goes from typing a hex into a swatch to re-authoring and re-exporting.

The deciding question is what this sells. It sells "the shopper sees their exact choices, and those choices reach the order" — a data-plumbing problem Rive does not touch. Line item properties still cannot change the price with Rive in the stack.

Where it would make sense is a separate premium tier for vector-authored, animated configurators: different buyer, different price, different repo. Folding it into this one would trade the property that makes it installable — 27 KB and a JSON file — for capabilities most merchants will not use.


8. Status

16 commits, ~9,200 lines of source across three packages plus the theme files.

  • 215 tests — 146 schema, 69 visualizer. Lint, format and typecheck clean.
  • Storefront bundle 27.6KB gzip, under a 30KB budget enforced in CI. The budget has been raised twice — 25→28 for text support, 28→30 for shopper-selectable text style — and each raise names its feature in scripts/report-size.mjs, because a budget that drifts up quietly is not a budget. 30KB is also what the neighbouring DripFitLab widget budgets, so the next raise has no external number to lean on and should mean cutting something instead.

The one thing not verified end to end is line item properties appearing on a real order in the Shopify admin — that needs a dev store and shopify theme dev. The mechanism is proven locally through the same new FormData(form) call an AJAX cart makes, but the store round-trip is untested.