Skip to content

ADR-0019 · Setu Card rendering, preview, versioning & switching ​

Status: 🟢 Accepted — adopted 2026-08-04 · Depends on: ADR-0003 (block model, JSON-manifest storage), ADR-0011 (surface-matched frontend — the public card is Stack 1), ADR-0015 (systemic surface, design-first, check:design fails closed), ADR-0014 (field allow-list) · Related: ADR-0004 (inert promo seam), ADR-0006 (templates:demo-all precedent), ADR-0007 (deferred feature_code gate), ADR-0009 (archetype compatibility), ADR-0010 (card event taxonomy, profile_analytics rollup)

The one-line thesis

There is one renderer for the Setu Card — DOM, server-rendered, on Stack 1 — and it never gains an RN counterpart. What looked like a rendering problem (the merchant's in-app preview vs the live public card, ADR-0011's open "visual seam" question) was actually two different products sharing one name; splitting fidelity preview from editing affordance removes the need for a second renderer entirely. Everything else here — versioning, lossless switching, the palette cascade — exists to keep that one renderer trustworthy over an unbounded number of future template authors, including an AI one.

Context ​

The Setu Card + Catalog foundation design session (2026-08-04) needed to answer four questions the earlier ADRs left open or didn't anticipate: how a merchant previews a template choice without a second renderer; how a template's colour scheme reaches a Catalog section with zero vendor configuration; how "a live vendor card must never break" becomes an enforceable guarantee rather than a promise; and how "switch templates freely, lose nothing" survives contact with a real manifest format. ADR-0003 (amended the same day) decided the manifest is a closed JSON block vocabulary, never markup — this ADR decides everything about how that manifest becomes a rendered, versioned, switchable card.

Three verified findings shaped every decision below:

  1. ADR-0011 named the preview problem as an open, unresolved mitigation ("a merchant previewing their card in-app is RN-rendered while the live public card is DOM-rendered — must match... or a webview preview of the real card"), and ADR-0006 D2 had already shipped the answer without anyone naming it as such: templates:demo-all opens the real DOM public cards from the admin PWA for field-demo. The precedent existed before the question was ever formally resolved.
  2. A per-subtree colour override is ~3 lines of CSS on DOM and structurally impossible in RN's styling channel. react-native-css-interop lifts only :root/.dark:root — there is no cascade, and no ThemeProvider exists anywhere in apps/mobile. This asymmetry is the technical reason there is one renderer, independent of and stronger than any argument about AI-authoring safety.
  3. RN has a second colour channel with no such constraint. useThemeColors() → buildThemeColors(scheme) returns resolved hsl() strings read imperatively — SetuCardPreview.tsx reads 7 colours this way and zero colour class names. So RN gaining palette awareness for its own small surfaces (never a card render) is a ~40-line context, not new theme infrastructure.

Decision 1 — one renderer; "preview" was always two different products ​

The public Setu Card renders exactly once, in DOM, on Stack 1 (ADR-0011). There is never an RN card-template renderer, in R1 or afterward. What needed solving was not a second renderer — it was that "preview" named two genuinely different products, and building one thing to serve both is what made this look unsolvable:

ProductQuestion it answersImplementation
Fidelity preview"What will my customer actually see?"expo-web-browser — already a dependency, already used by socialAuth.native.ts — opens the real, live card URL. Android Custom Tabs / iOS SFSafariViewController / RNW window.open.
Editing affordance"What's the effect of this choice, while I'm still choosing?"Palette swatches + a per-template thumbnail generated at publish time. Not a renderer, so it structurally cannot drift from the DOM card — it never attempts to reproduce it.

react-native-webview is explicitly rejected. It would add a new native module, a new prebuild step, no RNW implementation, and a new iframe parity seam — for a problem the browser preview already solves with a dependency already in the tree.

SetuCardPreview.tsx (the onboarding delight moment) is deliberately NOT template-aware, and this is a rule, not an oversight. The moment templates exist, the obvious "improvement" is to make it render the real manifest for fidelity — which reopens exactly the seam this decision closes, with no failing test to catch it. A check:parity rule enforces this structurally: no import of the card-manifest schema from apps/mobile/**.

Decision 2 — the palette cascade, and why it is DOM-only ​

Palette is a separate axis from Scheme, never a third Scheme value. type Scheme = 'light' | 'dark' stays closed; theme.css, build-theme-css.mjs, and its three css-interop invariants (@cssInterop must be the first rule; .dark:root, never bare .dark; no prefers-color-scheme block) are untouched. Extending Scheme would make the light+dark mandate inexpressible in the type, break elevation()'s Record<Scheme, …>, and require 2N root selectors of which 2N−2 cannot be lifted on native — the exact class of defect QRS-206 already cost a session to fix once.

Catalog theme inheritance is a property of the CSS cascade, not code. The Catalog is a section type inside the card's palette-scoped wrapper. On DOM, custom properties inherit for free, and bg-surface already compiles to hsl(var(--surface)) via tooling/tailwind-config's v() helper — so the Catalog section picks up the vendor's palette with zero wiring and zero vendor configuration. There is no Catalog theme to keep in sync, because the Catalog has no theme of its own.

RN gains a palette-aware colour channel, not a cascade. useThemeColors()'s imperative hsl() path has no css-interop constraint, so CardPaletteProvider + useCardColors(scheme, palette) (~40 lines) lets apps/mobile's small palette-aware surfaces (swatches, thumbnails) render correctly without inventing a ThemeProvider or a style cascade RN cannot express. This is the mechanism that makes Decision 1's editing affordance possible without a card renderer.

Trap — prefers-color-scheme must never enter packages/tokens/src/theme.css

The public card following the visitor's OS theme is correct on that surface, and is the exact opposite of the app's own rule: theme.css binds :root/.dark:root to the in-app scheme the merchant chose, not the visitor's OS (QRS-201 — binding those variables to the OS makes them disagree with the in-app preference). A visitor-selectable dark mode for the public card (D4, 26.0.2+) belongs in apps/web's own stylesheet, never in the shared token package's theme.css. check-parity.js greps for exactly this.

Decision 3 — template versioning: three independent locks ​

Content lives in the repo; metadata lives in a table. Neither duplicates the other. Per ADR-0003's amendment, a template is a repo-authored, CI-validated JSON manifest file — never a DB-authored row. card_templates is a registry, seeded by migration from the manifest files, so registry/file drift is impossible by construction:

ColumnPurpose
template_key text, version integerComposite PK. A profile pins an exact (key, version) — never "latest": vendors choose when to upgrade.
status text CHECK (active|deprecated|retired)active = selectable + renderable · deprecated = renderable, not newly selectable · retired = neither. Admin-mutable, no deploy needed.
manifest_schema_version integerWhich renderer contract this manifest speaks.
archetype_keys text[]Per ADR-0009: templates declare archetype compatibility as config.
feature_code textEntitlement gate, resolved through ADR-0007's registry — not a hardcoded tier string. Deferred T6: real column, runtime resolution deferred until a second, tier-gated template exists.
available_from, available_untilSeasonal windows — gate selection only, never rendering (Decision 4).
display_name, thumbnail_path, published_at, deprecated_at, retired_atPicker metadata + lifecycle audit.

Admin may mutate status and feature_code; admin may never create a row, because that would create content with no review gate.

"A live vendor card never breaks" is not a promise — it is three independent locks, and any one alone is insufficient:

  1. Manifests are immutable. A published version's file is never edited; a change is a new version. Enforced by review, and by check:templates validating every version on every commit, so an old version cannot silently rot.
  2. Retirement is bounded by live usage. A BEFORE UPDATE trigger on card_templates refuses status = 'retired' while any profile pins that (key, version) — the direct analogue of requires_min_app_build being bounded by the oldest live app build, and pgTAP-testable the same way.
  3. The renderer declares what it supports. SUPPORTED_MANIFEST_SCHEMA_VERSIONS is a constant in apps/web; check:templates asserts every non-retired registry row's manifest_schema_version is in that set. A renderer therefore cannot silently drop support for a version still in use.

Block-prop evolution is expand-contract, applied to the manifest. Within one manifest_schema_version, block props are additive-only, new props optional-only. A breaking block change is a new block type (header → headerV2) or a new schema version — the same discipline this repo already mandates for SQL.

Upgrade UX is vendor-initiated only. The picker shows "v2 available" with a preview diff; nothing auto-migrates. Admin can deprecate (stops new selections) but cannot force an upgrade.

Decision 4 — lossless switching, forever ​

Switching a template is one UPDATE of profiles.card_template_key/card_template_version. No vendor data moves, because no vendor data was ever stored in a template — this is the direct payoff of the Catalog being business-owned data rendered by a template, not owned by one. A printed QR stays valid because it encodes the slug URL, never a template-specific one.

This stays true only if one invariant is enforced — T12, gated in check:templates:

A manifest block may REFERENCE a field. It may never CONTAIN vendor content. Props are field references, i18n keys, or enum/numeric presentation values — never free-form vendor-specific strings. Without this, someone eventually adds a "custom headline" prop directly to a template, and from that moment switching silently destroys vendor content the instant the vendor switches away from it.

Two distinct guarantees, tested separately — conflating them is the trap:

  1. Readiness is a pre-flight courtesy. Each block declares requires: [...] / optional: [...] field names. evaluateTemplateReadiness(manifest, profile, items) → { missing[], degraded[] } — pure, in packages/domain/src/card-template/readiness.ts, no DB, node --test-able exactly like @qrsetu/domain's recurrence logic. The picker calls it before switching and shows what will change, what is missing, and what will be hidden.
  2. Graceful degradation is the actual safety net. A field can be emptied after a switch — the vendor deletes their cover photo next week. A block whose required field is absent hides, the same rule already applied to the Catalog section auto-hiding while empty. Building only the pre-flight check and skipping this is the trap: readiness answers "is this safe to switch to right now", degradation answers "does this stay safe forever after."

Seasonal templates reuse the exact same pattern a third time. available_from/available_until gates selection only — a Diwali template stops appearing in the picker in January; a vendor still using it keeps rendering perfectly. This is the identical principle already established twice elsewhere (entitlement gates selection not rendering; retirement is bounded by live usage, Decision 3). Reusing it a third time is a sign the abstraction is right, not a coincidence.

Palette survives a template switch. A template may declare a default_palette that the picker offers — but the vendor's explicit palette choice is preserved, never overwritten. A festival template must not silently discard a vendor's brand colour.

Decision 5 — the public card renders light, deterministically, in R1 ​

Every palette must define both light and dark (schema-enforced, contrast-gated) — but the public card itself renders light only in R1, because the SSR HTML, the Cloudflare cache entry, the WhatsApp OG raster, and what the merchant previewed at publish time must all agree on one rendering. Visitor-selectable dark mode is a 26.0.2+ decision (see the trap in Decision 2 for where it must live when built).

Decision 6 — ⚠ BLOCK-VOCABULARY GOVERNANCE: a new block type must serve ≥2 industries, or be a config variant ​

Amendment, 2026-08-10. Added because the owner's one-industry-at-a-time decision creates a cost this ADR did not price, and it is the only way the closed vocabulary can be broken.

Decisions 1-5 assume a closed block vocabulary — that is what makes a manifest validatable, AI-authorable and losslessly switchable. The owner then established, correctly, that a real-estate card and a jewellery card must not look alike, and that industries will be researched and shipped one at a time. Those two facts collide: a genuinely differentiated card wants industry-shaped blocks (a daily gold-rate block, an EMI calculator, a class schedule, a virtual tour, a scheme table). At ~30 industries × 2-3 bespoke blocks that is 60-90 block types, each one a code change, a design pull and a drift-ledger row.

⚠ That is ADR-0021 D4's conditional sprawl arriving through the template layer instead of through app code, and nothing currently governs it. The vocabulary being closed is not self-enforcing: nobody would ever propose "let templates contain markup", but everybody will propose "just one more block type", and thirty of those is the same outcome reached politely.

So the gate, deliberately mirroring ADR-0020's primitive rule:

A new block type requires a written justification that it serves ≥2 industries. Otherwise it must be expressible as a CONFIG VARIANT of an existing block — a variant enum, a different label, a different density, a different media aspect.

Why ≥2 here and ≥3 for a primitive: a primitive carries schema, RLS, RPCs and a resolver term, so its cost is structural and permanent. A block type carries a renderer component and a schema entry — real, but an order of magnitude cheaper — and demanding ≥3 would refuse blocks that genuinely serve a pair of related industries (jewellery and a scrap-metal dealer both want a published-rate block).

Where differentiation is supposed to come from instead, and it is most of the available range:

Free (data or config)Governed (code)
Block order — imagery-led vs action-led vs information-ledA new block type
Per-block variant enumA new manifest schema version
Palette — the single largest perceived difference
Density and type scale props
Labels via t() — "Watch Anytime" vs "Virtual Tour" over one video block
Media aspect ratio — 1:1 for a saree, 16:9 for a walkthrough
Which blocks are PRESENT, driven by the industry's primitive composition

⚠ Read the last row twice, because it is the load-bearing one and it costs nothing: a festival stall, a jeweller and an estate agent already differ in composition, so they already differ in which blocks apply. Much of the "these cards look identical" problem was not a vocabulary gap at all — it was ten personas rendered through one fixed section order, which is a template defect rather than a block defect.

Enforcement: check:setu-card-templates gains a rule asserting every block type present in any manifest appears in a registry that records, per type, the industries it serves and the justification. A block type used by one industry with no written ≥2 case fails the gate.

Consequences ​

  • The check:templates gate is the enforcement surface for this ADR's guarantees, not a style-lint. It proves, in both directions (a deliberately-broken fixture must fail; the fix must pass): manifest shape and unknown-prop rejection, both-schemes-complete, contrast, no raw colour literal, every user-facing string is a t() key, the field allow-list mirrors ADR-0014's public projection (T9), and T12 (no vendor content in a manifest).
  • Versioning is proven negatively, which is the point. Pin a profile to a version, attempt to retire it → the trigger refuses. Un-pin, retry → succeeds. Remove a manifest file whose version is still registered → check:templates fails. A guarantee tested only in its passing direction is not a guarantee.
  • apps/mobile never imports the card-manifest schema. Enforced as a check:parity rule, not a convention — see Decision 1.
  • The ad/sponsorship seam (ADR-0004) rides this ADR's manifest shape: a promo_slot block type ships now, inert (resolvePromo() always returns null), because ad_restricted/compliance_profile does not exist yet on business_domains.
  • Card analytics and the profile_analytics rollup are governed by ADR-0010, not here — this ADR only fixes the point at which a card-affecting write must trigger a cache purge (a card-affecting write with no invalidation is a defect against Decision 1's "what the merchant sees" promise, tracked as QRS-350/351, not an open question of this ADR).
  • ADR-0003 — the block model and JSON-manifest storage this ADR builds on.
  • ADR-0011 — 2026-08-04 amendment resolving its open visual-seam question in favour of Decision 1 here.
  • ADR-0006 D2 — the templates:demo-all precedent Decision 1 generalises.
  • ADR-0014 — 2026-08-04 amendment binding T9 to its projection.
  • ADR-0015 — packages/tokens/** is the systemic surface; the palette layer is design-first, one drift-ledger row per pull.
  • Tracker: QRS-344 (template framework contracts), QRS-345 (palette layer), QRS-346 (check:templates gate), QRS-347 (registry + retirement gate), QRS-355 (switching: readiness + degradation + T12 + seasonal windows).