Documentation
System prompt for Claude Design / Stitch
This is the brief a designer uses to seed a Claude Design or Google Stitch
session for the getbookable platform. The canonical operator-facing copy
is served at {{PLATFORM_HOST}}/system-design-prompt.txt; designers point
the design tool at that URL rather than pasting the contents (see
docs/designer-guide.md). This markdown file is the source of truth that
the public route reads at request time.
If you change the contract behaviour, bump the contract version in
HANDOFF_CONTRACT.mdandsrc/import/contract.tsto match. Editing framing or wording here without behavioural change is fine and does not need a version bump.
Hello — I'm the designer briefing you for this session. I'm working on a
website for a booking-based small business, and the design we produce here
will be exported as a Claude Design handoff bundle and read by a small,
mechanical importer that turns it into a tenant site on our getbookable
platform. The importer does not infer or interpret — it expects very
specific filenames and a specific data shape, so please structure the
output so the conventions below hold. They're not abstract rules; they're
what makes my export usable.
This document describes only the structural conventions — what files to produce, what to name them, and what shape the data must take. The business description, audience, tone, palette, and creative direction for this specific tenant are supplied separately in the message that points you at this URL. If that context didn't accompany the request, please ask me for it before producing the design.
Conventions for the export
-
One file per block, named exactly. Please name component files in the project's
project/directory using one of these filenames, case-sensitive:Header.jsx,Hero.jsx,ServiceList.jsx,Gallery.jsx,Testimonial.jsx,AboutPanel.jsx,Products.jsx,BookForm.jsx,Footer.jsx,ContactInfo.jsx,ContactHours.jsx,PricingTable.jsx,FAQ.jsx,OpeningHours.jsx,TeamGrid.jsx,CTABanner.jsx. A subset is fine; new filenames or helper files won't survive import. -
Single top-level content constant per file. Please structure each JSX file so it contains exactly one top-level
const DATA = { … }declaration. The function body should read fromDATA(e.g.DATA.services.map(…)) rather than hardcoding user-visible content elsewhere.The per-block field schemas (which keys each
DATAobject should have, and their types) are documented in a companion file — please fetch the contract at this exact URL:{{PLATFORM_HOST}}/handoff-contract.txt(a full, absolute URL — fetch it directly, don't resolve a relative path) and follow each block's documented shape. Key names are case-sensitive (heading, nottitle;lead, notsubtitle; etc.). What actually renders is whatever your JSX function uses fromDATA— the importer serves your captured HTML verbatim, so any key your component reads ends up on the page. The documented schemas matter because the tenant-admin's per-block edit forms are driven by the typed fields populated from documentedDATAkeys. Undocumented keys still render (the captured HTML is verbatim) but won't be editable by tenants. Stick to the documented schemas so prose content stays editable in-admin and re-imports stay predictable.A common trap is the
AboutPanelstats row (the end-of-panel figures like15+ / years advising). Express these as an optionalDATA.statsarray of{ value, label }entries and render the row withDATA.stats.map(...)— do not hardcode the figures as inline<div>markup. Hardcoded figures render but stay frozen; onlyDATA.statsentries become tenant-editable typed fields. Omit the key entirely if the design has no stats row. For example:const DATA = { eyebrow: 'Hello', heading: "I'm Amanda.", body: 'Twenty-five years …', stats: [ { value: '15+', label: 'years advising' }, { value: '40+', label: 'teams helped' }, ], } // …in the component body: { DATA.stats?.map((s, i) => ( <div key={i}> <div className="gb-stat-value">{s.value}</div> <div className="gb-stat-label">{s.label}</div> </div> )) } -
Header and Footer are tenant-wide. Please produce one Header and one Footer; the platform applies them to every page automatically. No need to duplicate per page.
-
BookForm is a placeholder — use the canonical one verbatim. Please do not invent your own booking UI. Copy the canonical
BookForm.jsxbelow intoproject/BookForm.jsxexactly as written (adjust only the prose strings insideDATA—eyebrow,heading,lead,labels.*,aside.*,location, and thesessions[]to match this tenant). It faithfully mocks the platform's real multi-step booking widget — a step indicator ("Step 1 of 4"), session cards rendered fromDATA.sessions, a primary Continue action, and a "Booking by Get Bookable" footer — so your book page previews correctly in the design tool. At render time the platform replaces this file entirely with the live widget, which inherits your theme tokens (--brand-*); none of this JSX reaches production. So spend no effort restyling it — just keep the structure and fill in the copy.Footprint to design around: the live widget renders as a full-width hero band (a heading region using your
eyebrow/heading/lead) with an adjacent "what to expect" aside (fromaside.{title, points, footnote}) beside the multi-step card — not as a compact inline form. Compose the surrounding page rhythm (the sections above and below/book) against that taller hero-band + aside footprint so the imported page matches your design.What the importer keeps from
DATA:eyebrow,heading,lead,labels.*(the widget's in-chrome field copy),aside.{title, points, contactLabel, email}, andlocation.sessions[]is consumed only to seed the tenant's bookable services. Everything else — including the JSX itself and anydays/slotsByDay/takenmock data — is discarded. Use theselabelskeys exactly:name,phone,email,service,preferredDay,timeOfDay,notes,submit.// BookForm.jsx — canonical booking-widget placeholder. // // Do NOT design your own booking UI. Copy this file verbatim. At render // time the platform REPLACES it entirely with the live multi-step // booking widget, which inherits your theme tokens (--brand-*). This // placeholder exists only so your book page previews faithfully in the // design tool — none of its markup reaches production. // // Importer keeps: eyebrow, heading, lead, labels.*, // aside.{title,points,contactLabel,email}, location. Consumed for // services seeding only: sessions[]. Discarded: this JSX plus any // days / slotsByDay / taken mock data. const DATA = { eyebrow: 'Book', heading: "Let's get you in the diary.", lead: 'Choose a session, pick a time, and pop in your details.', labels: { name: 'Your name', phone: 'Phone', email: 'Email', service: 'Choose a service', preferredDay: 'Preferred day', timeOfDay: 'Available times', notes: 'Anything we should know?', submit: 'Confirm booking', }, aside: { title: 'What to expect', points: [ 'A friendly chat with a real person — not a call centre.', 'A clear summary and next steps by email.', 'Easy rescheduling if your week changes.', ], contactLabel: 'Prefer email?', email: 'hello@example.com', }, location: 'Remote · video call', sessions: [ { id: 'discovery', name: 'Discovery call', duration: '30 min', price: 0, desc: 'A short intro to scope what you need.', }, { id: 'standard', name: 'Standard appointment', duration: '60 min', price: 65, desc: 'The usual visit, start to finish.', }, { id: 'half-day', name: 'Half-day session', duration: 'Half day', price: 400, desc: 'A focused block for bigger pieces of work.', }, ], } function BookForm() { // Step 1 of the real four-step flow (service → time → details → // confirmed). The placeholder paints the first step statically; the // live widget drives the rest. const totalSteps = 4 const currentStep = 0 const cardStyle = { background: 'var(--brand-surface, #fff)', color: 'var(--brand-ink, #1a1a1a)', border: '1px solid var(--stone-2, #e2ddd4)', borderRadius: 'var(--radius-card, 18px)', padding: 'clamp(16px, 4vw, 26px)', width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18, } return ( <section className="gb-bookform" style={{ display: 'flex', flexWrap: 'wrap', gap: 28, alignItems: 'flex-start', fontFamily: 'var(--font-body, system-ui, sans-serif)', }} > <div style={cardStyle}> <header style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <p style={{ margin: 0, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--brand-primary, var(--brand-accent, #5b9a2f))', }} > {DATA.eyebrow} </p> <h2 style={{ margin: 0, fontFamily: 'var(--font-display, var(--font-body, system-ui, sans-serif))', fontSize: 'clamp(1.3rem, 3.5vw, 1.7rem)', lineHeight: 1.2, }} > {DATA.heading} </h2> <p style={{ margin: 0, fontSize: 14, color: 'var(--stone-5, #555)' }}>{DATA.lead}</p> </header> <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}> {Array.from({ length: totalSteps }).map((_, i) => ( <span key={i} aria-hidden="true" style={{ width: i === currentStep ? 24 : 8, height: 8, borderRadius: 9999, background: i <= currentStep ? 'var(--brand-ink, #1a1a1a)' : 'var(--stone-2, #ddd)', }} /> ))} <span style={{ marginLeft: 10, fontSize: 12, color: 'var(--stone-4, #777)' }}> Step {currentStep + 1} of {totalSteps} </span> </div> <div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--stone-4, #777)', }} > {DATA.labels.service} </div> <div role="radiogroup" aria-label={DATA.labels.service} style={{ display: 'flex', flexDirection: 'column', gap: 10 }} > {DATA.sessions.map((s, i) => ( <button type="button" key={s.id || i} role="radio" aria-checked={i === 0} style={{ display: 'flex', gap: 14, alignItems: 'stretch', textAlign: 'left', padding: '14px 16px', borderRadius: 14, cursor: 'pointer', border: i === 0 ? '1.5px solid var(--brand-primary, var(--brand-ink, #1a1a1a))' : '1px solid var(--stone-2, #ddd)', background: i === 0 ? 'var(--brand-surface, #fff)' : 'var(--paper, #fff)', color: 'var(--brand-ink, #1a1a1a)', }} > <span aria-hidden="true" style={{ flex: 'none', width: 4, borderRadius: 4, background: 'var(--brand-accent, #b8a068)', }} /> <span style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}> <span style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', flexWrap: 'wrap', }} > <span style={{ fontWeight: 600, fontSize: 16 }}>{s.name}</span> <span style={{ fontWeight: 600, color: 'var(--brand-accent, #b8a068)' }}> {s.price === 0 ? 'Free' : '£' + s.price} </span> </span> <span style={{ fontSize: 13, color: 'var(--stone-5, #555)' }}> {s.duration} {s.desc ? ' · ' + s.desc : ''} </span> </span> </button> ))} </div> <div style={{ display: 'flex', justifyContent: 'flex-end' }}> <button type="button" style={{ minHeight: 44, padding: '0 20px', borderRadius: 10, border: 'none', background: 'var(--brand-primary, var(--brand-ink, #1a1a1a))', color: 'var(--brand-primary-text, #fff)', fontWeight: 500, fontSize: 14, cursor: 'pointer', }} > Continue → </button> </div> <p style={{ margin: 0, fontSize: 12, color: 'var(--stone-4, #777)', textAlign: 'center' }} > Booking by Get Bookable </p> </div> <aside style={{ flex: '1 1 220px', minWidth: 220, fontSize: 14 }}> <h3 style={{ marginTop: 0, fontFamily: 'var(--font-display, inherit)' }}> {DATA.aside.title} </h3> <ul style={{ paddingLeft: 18, color: 'var(--stone-5, #555)', lineHeight: 1.6 }}> {DATA.aside.points.map((point, i) => ( <li key={i}>{point}</li> ))} </ul> <p style={{ color: 'var(--stone-5, #555)' }}> {DATA.aside.contactLabel}{' '} <a href={'mailto:' + DATA.aside.email} style={{ color: 'inherit' }}> {DATA.aside.email} </a> </p> <p style={{ fontSize: 13, color: 'var(--stone-4, #777)' }}>{DATA.location}</p> </aside> </section> ) } window.BookForm = BookForm
4a. Content slot markers (v2.1.0). Six blocks render content that the
tenant owns in Payload after first import. Their JSX must wrap the
dynamic content region in a <div data-gb-slot="<token>">. The
wrapping element preserves your section chrome (heading, grid
wrapper); the inner children are replaced at render time with live
data from Payload. The preview content you ship is a sizing /
layout reference at import time and is never shown to a visitor.
| Block | Slot marker |
|---|---|
Gallery.jsx | <div data-gb-slot="gallery"> |
Products.jsx | <div data-gb-slot="products"> |
ServiceList.jsx | <div data-gb-slot="services"> |
Testimonial.jsx | <div data-gb-slot="testimonials"> |
ContactInfo.jsx | <div data-gb-slot="contact"> |
Footer.jsx | <div data-gb-slot="social"> (wrap the social-icon row) |
Example for Gallery.jsx:
function Gallery() {
return (
<section className="gb-gallery">
<h2>{DATA.heading}</h2>
<div data-gb-slot="gallery">
{DATA.images.map((i) => (
<figure key={i.image}>
<img src={i.image} alt={i.alt} />
</figure>
))}
</div>
</section>
)
}
Bundles whose rendered JSX is missing the required marker are
rejected by the importer with a structured error. See
HANDOFF_CONTRACT.md → Slot markers for full details and the
first-import-vs-re-import semantics.
4b. Sentinel-render rule (v2.1.0). The importer re-renders each
slot-bearing block with a SINGLE-ITEM (or 2-item for Testimonial)
DATA array to extract the per-item HTML template. Your .map(...)
must iterate without short-circuits or minimum-count guards:
{DATA.images.map((t, o) => <figure key={o}>...</figure>)} // correct
{DATA.images.length === 0 ? null : DATA.images.map(...)} // forbidden
{DATA.images.length < 2 ? null : DATA.images.map(...)} // forbidden
{DATA.images.slice(0, 3).map(...)} // forbidden
Quotes in Testimonial.jsx may carry an optional rating: 1-5. If
omitted, the platform defaults to 5 stars and stamps the stars row
per-quote at render time.
-
Theme tokens. Please put all colours, fonts, and radii in
colors_and_type.cssas:rootCSS custom properties. Required property names:--brand-primary, --brand-primary-text, --brand-accent, --brand-surface, --brand-ink, --font-display, --font-body, --radius-cardReference these tokens from
styles.cssrather than inlining colour or font values elsewhere. -
Assets. Please place every image, logo, and photo under
project/assets/orproject/uploads/, and reference them inDATAas relative paths beginning withassets/oruploads/. If you don't actually have a file for an image-bearing field (e.g. a product'simage, a team member'sphoto), please omit that field entirely — do not reference a placeholder path likeassets/products/placeholder.pngthat isn't in the bundle. The importer rejects the whole bundle on any missing-asset reference; it cannot silently skip a single broken image. -
Components render twice: server-side for initial paint, then in the browser for interactivity. The importer renders each JSX file server-side at import time (the captured HTML is what crawlers and no-JS visitors see) AND the same JSX is bundled into a per-tenant client JS bundle that the browser mounts on
DOMContentLoaded, replacing the server-rendered tree with a live React tree.What this means in practice:
- Hooks work at runtime.
React.useState,React.useEffect,React.useRef,React.useMemoall behave normally in the browser. Server-side, the import-time sandbox stubs them so the initial paint capturesuseState's initial value and no effects fire — that's the right shape, since the client mount is what hydrates state and runs effects. - Don't reference
window/documentat module-load time. InsideuseEffect/ event handlers is fine. Pattern that breaks:const [matches, setMatches] = useState(window.matchMedia(...).matches)—windowis stubbed at import time, so SSR captures a wrong initial value. Pattern that works:const [matches, setMatches] = useState(false); useEffect(() => { setMatches(window.matchMedia(...).matches) }, []). window.X = XIIFE-export still works. The platform's client bootstrap discovers block components by readingwindow.<BlockName>from the bundle. Keep the existing;(() => { const X = ...; window.X = X })()pattern.<button onClick>and<a href>are both fine. Pick the semantic one for the element's role. The client bundle attaches real event handlers, so the previous "no<button onClick>" guidance no longer applies.<a href="/<slug>">is a real cross-page link. When<slug>matches one of the page slugs you compose inindex.html(see point 10 below), the platform serves a distinct page at that URL and the browser performs a normal full-page navigation. Fragment anchors (<a href="#about">) still work for in-page jumps within a single page — use them for "scroll to the contact section". Writing<a href="/<slug>">for a slug that ISN'T one of your bundle's page slugs falls back to a same-page fragment anchor (#<slug>), so a broken cross-page link won't 404 the visitor.setInterval/setTimeoutwork inuseEffect. The canonical autoplay carousel pattern (interval inside auseEffectwith a cleanup) is supported.- No npm imports. Each block JSX file must still be
self-contained. The only modules resolvable at runtime are
react,react-dom/client, andreact/jsx-runtime— those are externalised from the per-tenant bundle and served by the platform. - Design-system component bundles aren't loaded either. Claude
Design's design-system skill may suggest reading
_ds/.../_ds_bundle.jsand composing with its exported components viawindow.<DSName>_<hash>.*. Our sandbox doesn't evaluate sibling bundles or expose their globals — any reference towindow.<DSName>…isundefinedat import time and crashes the render. Reproduce brand identity through the design tokens (--brand-*, gradients, radii, etc.) inside each self-contained block instead. The DS adherence linter will report "0 of N components used"; that's expected and not a defect. - Bundle size budget: 10 KB minified per block file, 200 KB
total. The bundler reports the total bundle size (the
bundleBytesfield) in the import audit log.
- Hooks work at runtime.
-
Use the exact
daycodes in OpeningHours:mon,tue,wed,thu,fri,sat,sun. -
Use the exact
platformcodes in Footer.socialLinks:instagram,facebook,x,tiktok,youtube,linkedin. -
Compose multiple pages in
project/index.html. Please write anAppcomponent whose JSX returns a fragment with one branch per page, gated on apage === '<slug>'check.HeaderandFootergo outside the branches because they're tenant-wide. Example:function App() { const [page, setPage] = React.useState('home') return ( <> <Header /> {page === 'home' && ( <> <Hero /> <ServiceList /> <AboutPanel /> </> )} {page === 'services' && ( <> <ServiceList /> <PricingTable /> </> )} {page === 'products' && <Products />} {page === 'book' && <BookForm />} <Footer /> </> ) }Each slug becomes a URL (
home→/,services→/services, etc.). Custom slugs work but MUST match^[a-z0-9][a-z0-9_-]*$(case-insensitive): a single URL segment starting with a letter or digit, then any mix of letters, digits, hyphens, and underscores. No spaces, dots, slashes, or other characters — branches with invalid slugs are silently dropped at parse time. Use the recognised shape —===(or==) with&&and a string literal on the right; other shapes (||,page.includes(...), computed strings) aren't recognised and trigger the fallback below. Navigation between pages is a real full-page reload, not SPA routing — by design — so every page lands with a correct initial paint.If
index.htmlis missing or unrecognised the importer falls back to a single-page render at/(every block concatenated in canonical order). The bundle is still accepted; it just won't have per-page URLs.
If something in my brief conflicts with one of these conventions, please
follow the convention and flag the conflict back to me — the importer
will reject bundles that don't fit, and loose interpretation costs me a
re-export. (For reference, the importer this targets is at contract
version 2.1.0, defined in src/import/contract.ts; no need to mention
the version in your output.)
Output format
Please use Claude Design's normal canvas — HTML/CSS/JSX prototypes you can preview. When the design is ready, I'll trigger Claude Design's "hand off to coding agent" export myself. The conventions above are what make that export land cleanly on the other side.