Appearance
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:
type | Purpose | requires | optional |
|---|---|---|---|
header | Brand name, hero image | brand_name | avatar_url, description |
contact | Social/chat links, location | — | social_media_links, city, state, country |
hours | Business hours table | business_hours | — |
catalog | The Catalog section — renders profile_items, auto-hides while empty | — (self-hides) | — |
qr | The card's own QR code, for reciprocal display | slug | — |
footer | Legal links, attribution, share affordance | — | — |
promo_slot | Reserved, 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
- None of
tagline,logo_url,cover_image_url,closed_noteis a real column onprofiles— verified against the live schema. The real columns covering the same intent aredescription(loosely, "tagline") andavatar_url("logo"); there is no separate cover-image field and no structured "closed note" — checked, not assumed. mobile_number,emailandaddress(a non-existent column; the real ones arecity/state/country, never a street address) are excluded on purpose, not by omission. ADR-0014 namesemail/mobile_number/gstin/pin_codeas 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 acontactblock requiringmobile_numberwould have reopened that class of exposure through a new door (a template file) instead of the old one (a missingTOclause).- 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
variantenum (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
motiffor 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:
- 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. - An i18n key — resolved through
@qrsetu/i18n, never a hardcoded phrase in any language. - An enum or numeric presentation value — a
variant, amotifkey, 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).
| Rule | Checks | Rationale |
|---|---|---|
| T1 | Manifest shape matches SetuCardTemplate; no unknown block type or prop | The closed vocabulary is enforced structurally, not by convention |
| T2 | Every palette defines both light and dark, complete SemanticSet | The light+dark mandate is non-negotiable (CLAUDE.md Design System) |
| T3 | Contrast ≥ 4.5:1 for every content*/surface* pair, both schemes | Accessibility is polish, not a follow-up; a template that fails contrast is not premium |
| T4 | No raw colour literal (hex/rgb/hsl) anywhere in a manifest or palette outside the SemanticSet shape | Zero-hard-coded-colours is a repo-wide invariant; a template is not exempt |
| T6 | feature_code (if set) names a real row in the feature registry | Deferred 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) |
| T7 | Every user-facing string is a real key in the en i18n catalog | No hardcoded copy, ever — matches the reminders/onboarding precedent |
| T9 | Every field a block references is in the public projection (ADR-0014) — email, mobile_number, gstin, attributes, metadata are always refused | Binds 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 |
| T12 | No 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.Related
- 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-templatesgate).