Skip to content

Setu Card template authoring — Claude Design specification ​

Status: 🟡 specification only — no template has been generated against this doc yet (M2 is pending). Depends on: ADR-0003 (block model), ADR-0019 (versioning + switching), ADR-0014 (field allow-list).

What this document is for

This is the specification a Claude Design session follows to generate a new Setu Card template. The output is never code or markup — a manifest + a palette, both JSON data — because that is the whole reason AI authoring is safe here: there is no rendering logic to review, only data to validate. Everything below exists to make that validation automatic (check:setu-card-templates, M5/QRS-346) rather than a manual review.

The closed block vocabulary ​

A template manifest is an ordered array of blocks, each { type, props, requires, optional }. There is no custom/HTML block — cut deliberately, see ADR-0003's 2026-08-04 amendment. R1 ships exactly six renderable block types plus one reserved, inert one:

typePurposerequiresoptional
headerBrand name, hero imagebrand_nameavatar_url, description
contactSocial/chat links, location—social_media_links, city, state, country
hoursBusiness hours tablebusiness_hours—
catalogThe Catalog section — renders profile_items, auto-hides while empty— (self-hides)—
qrThe card's own QR code, for reciprocal displayslug—
footerLegal links, attribution, share affordance——
promo_slotReserved, inert. Declares WHERE a promotion could render; never declares WHAT.— (no field dependency of its own)—

Correction, 2026-08-04: mobile_number/email/address/tagline/logo_url/cover_image_url/

closed_note were in the FIRST draft of this table and are WRONG — removed while writing the actual schema (packages/schemas/src/setu-card-template.ts, M1), not carried forward

  1. None of tagline, logo_url, cover_image_url, closed_note is a real column on profiles — verified against the live schema. The real columns covering the same intent are description (loosely, "tagline") and avatar_url ("logo"); there is no separate cover-image field and no structured "closed note" — checked, not assumed.
  2. mobile_number, email and address(a non-existent column; the real ones are city/state/country, never a street address) are excluded on purpose, not by omission. ADR-0014 names email/mobile_number/gstin/pin_code as columns that must never reach the anon surface — the incident that ADR records was bulk exfiltration through exactly this kind of allow-list creep. A card renders for anonymous visitors, so a contact block requiring mobile_number would have reopened that class of exposure through a new door (a template file) instead of the old one (a missing TO clause).
  3. This leaves a genuine, unresolved product question, not quietly answered either way: a public business card conventionally shows a way to call/message the business, and social_media_links (already public, jsonb, populated at the vendor's own discretion) is the only field currently available for that — it is NOT a substitute if the product intent is "show the merchant's phone number directly." Recorded as an open question rather than resolved here: if a direct contact-number surface is wanted, it needs its own ADR-0014 amendment (the ADR's exclusion was written against a bulk-query threat model; whether it should apply unchanged to a single-slug RPC call is exactly the kind of question that amendment would need to answer), not a block spec quietly assuming yes.

The SETU_CARD_SETU_CARD_FIELD_REFERENCES allow-list a block may bind to lives in packages/schemas/src/setu-card-template.ts and is the actual source of truth this table summarises — read the code comment there for the full reasoning, and treat a disagreement between this table and that file as this table being stale.

A block's requires/optional field lists are what evaluateSetuCardTemplateReadiness() reads (ADR-0019 Decision 4) — get these right and the picker's "what's missing if you switch to this" UI is correct for free.

Adding real expressiveness happens by adding a new block type (a code change + a design pull + a drift-ledger row, per ADR-0015), never by adding a free-text prop to an existing one. Within one block type, variety comes from:

  • Block order — which sections appear and in what sequence.
  • A per-block variant enum (e.g. header.variant: 'centered' | 'banner' | 'compact') — a closed set, never a free string.
  • The palette (a full colour + type-scale swap, see below).
  • A token-referenced decorative motif for seasonal/festival ornament — a token key, never an uploaded asset or inline SVG/CSS.

What a manifest prop may contain — the rule that makes switching lossless ​

A block may REFERENCE a field. It may never CONTAIN vendor content.

Concretely, every prop value is one of:

  1. A field reference — a key naming a profile/catalog field (brand_name, mobile_number, …), resolved at render time from the vendor's own data. Never a literal string of vendor content.
  2. An i18n key — resolved through @qrsetu/i18n, never a hardcoded phrase in any language.
  3. An enum or numeric presentation value — a variant, a motif key, a spacing/density number. Closed sets only.

This is T12, gated in check:setu-card-templates, and it is the entire mechanism behind ADR-0019 Decision 4's lossless switching guarantee. A manifest that ever contains a literal vendor-specific string (a hardcoded headline, a one-off promo line) breaks that guarantee the instant a vendor switches away from it.

Palette seed shape ​

A palette is a named colour scheme, separate from Scheme (light/dark stays a closed 2-value union — see ADR-0019 Decision 2). Every palette must supply both a light and a dark SemanticSet, or it cannot ship (schema-enforced):

ts
type SemanticSet = {
  surface: string;        // hsl(...) — card background
  surfaceMuted: string;
  content: string;        // primary text
  contentMuted: string;
  border: string;
  accent: string;         // primary interactive/emphasis colour
  accentSoft: string;
};

type Palette = {
  key: string;             // e.g. 'setu', 'diwali-2026'
  displayName: string;     // i18n key, not a literal string
  light: SemanticSet;
  dark: SemanticSet;
};

Contrast is gated, not reviewed by eye. Every content* value against its paired surface* must clear WCAG AA (4.5:1) in both schemes — T3, computed by contrastRatio() (packages/tokens), not asserted by the design tool. A palette that fails contrast in one scheme cannot ship even if the other scheme passes.

Token names are fixed — do not invent new ones. Use exactly the SemanticSet keys above, compiled through @qrsetu/tokens. The one QR-monogram brand gradient (brand-qr-from/brand-qr-to) is off-limits to templates — it is the platform's own brand mark, not a per-vendor decoration; a template's accent is its own colour, never the platform gradient repurposed.

The check:setu-card-templates CI rules ​

Every rule below fails the build on violation and must be proven in both directions — a deliberately-broken fixture fails, the fix passes (QRS-013's lesson; see QRS-346).

RuleChecksRationale
T1Manifest shape matches SetuCardTemplate; no unknown block type or propThe closed vocabulary is enforced structurally, not by convention
T2Every palette defines both light and dark, complete SemanticSetThe light+dark mandate is non-negotiable (CLAUDE.md Design System)
T3Contrast ≥ 4.5:1 for every content*/surface* pair, both schemesAccessibility is polish, not a follow-up; a template that fails contrast is not premium
T4No raw colour literal (hex/rgb/hsl) anywhere in a manifest or palette outside the SemanticSet shapeZero-hard-coded-colours is a repo-wide invariant; a template is not exempt
T6feature_code (if set) names a real row in the feature registryDeferred in R1 — the column is real and gated by shape, but runtime resolution against user_subscriptions waits for a second, tier-gated template to exist (ADR-0007 note)
T7Every user-facing string is a real key in the en i18n catalogNo hardcoded copy, ever — matches the reminders/onboarding precedent
T9Every field a block references is in the public projection (ADR-0014) — email, mobile_number, gstin, attributes, metadata are always refusedBinds the manifest field allow-list to the ONE list this repo already governs, so a template can never re-open the QRS-001-class exposure through a new door
T12No block prop is a literal vendor-content string (see the rule above)The switching-losslessness invariant

Reserved, not yet assigned: T5, T10, T11 — left as gaps rather than filled with placeholder rules, so a future rule gets a number that means something (per the tracker's own "an id is an identity, not a status" norm). Candidates already named elsewhere that may claim one of these when M5 is actually built: a same-origin-only rule for any media URL a manifest or item references (G8 — same-origin Storage bucket only, closes an SSRF/tracking vector), and axe-core zero-violations on the rendered output (D11 — this checks the render, not the manifest file, so it may belong in a separate CI job rather than check:setu-card-templates itself; decide at M14).

The prompt to give Claude Design ​

Copy the block below into a Claude Design session when asking it to generate a new template. It constrains the output to exactly the shape check:setu-card-templates validates.

markdown
You are generating a Setu Card TEMPLATE for QRSETU. The output is DATA, not code: a JSON manifest (an ordered
array of blocks) and a JSON palette (a light + dark SemanticSet). You are NOT writing HTML, CSS, JSX, or any
markup or script of any kind.

RULES YOU MUST FOLLOW EXACTLY:

1. Use ONLY these block types: header, contact, hours, catalog, qr, footer, promo_slot. No other block type
   exists. Do not invent one.
2. Every prop value must be one of: (a) a field reference naming a real profile/catalog field — slug, brand_name,
   name, full_name, avatar_url, description, business_hours, social_media_links, city, state, country,
   default_currency — never a literal sentence you composed; (b) an i18n key; (c) a closed enum/variant/motif
   value you name explicitly. NEVER write a literal headline, tagline, or promotional sentence as a prop value —
   that is vendor content, and a template must never contain vendor content, only reference it. NEVER reference
   email, mobile_number, gstin, pin_code, attributes or metadata — those never reach an anonymous visitor.
3. Produce a palette with BOTH a "light" and a "dark" SemanticSet: surface, surfaceMuted, content, contentMuted,
   border, accent, accentSoft — all as hsl(...) strings. Every content*/surface* pairing must be visibly
   high-contrast in both schemes; you will be rejected if contrast fails WCAG AA (4.5:1).
3a. Do NOT reuse the platform's own brand gradient (a warm gold-to-coral QR-monogram gradient) as your accent —
    choose a distinct colour for this template's identity.
4. Do not write any copy text directly. If a block needs a label ("Contact us", "Opening hours"), name an i18n
   key for it (e.g. contact.heading) rather than the literal English words.
5. Output exactly two JSON objects: the manifest (block array) and the palette (light+dark SemanticSet). Nothing
   else — no explanation prose mixed into the JSON, no markup, no inline styles.
  • ADR-0003 — why the output is JSON, never markup.
  • ADR-0019 — versioning + switching this manifest format must satisfy.
  • ADR-0014 — the field allow-list T9 enforces.
  • ADR-0015 — packages/tokens/** is design-first; pull the palette from the Claude Design MCP project before authoring a new one, per M2.
  • Tracker: QRS-344 (manifest schema), QRS-345 (palette layer), QRS-346 (check:setu-card-templates gate).