Documentation
Designer guide
Practical handbook for designers (Claude Design / Google Stitch users)
working with the getbookable platform. The single source of truth for
the rules is HANDOFF_CONTRACT.md; this guide explains how to use it.
Workflow at a glance
1. Kick-off call with the tenant. Brief captured.
2. Open Claude Design (or Stitch) and start the session by asking the
tool to fetch the canonical brief:
"Please fetch {{PLATFORM_HOST}}/system-design-prompt.txt and follow
it as my brief for this session. The business description is …
and the design brief is …"
({{PLATFORM_HOST}} above is a full, ready-to-fetch URL for the platform
host you are reading these docs on — the same hostname the admin and
operator routes are served from; no hand-editing needed. The brief
lives in docs/designer-system-prompt.md in the repo and is served
verbatim from that URL.)
3. Iterate on the design. Use the canonical block filenames only.
Put all user-visible content in a top-level `const DATA = { … }`.
4. When happy, trigger Claude Design's "hand off to coding agent" export
to produce the .tar.gz handoff bundle.
5. Import the bundle. The primary path is self-serve: the tenant uploads
the bundle themselves, either during onboarding ("Bring your own
design") or later from their admin Import screen. (For designer-led or
Premium engagements the bundle can instead be imported on the tenant's
behalf by the platform.) On success, smoke-test the tenant URL.
6. If the importer rejects, fix the design and re-export. Errors are
structured: file, line, column, the failed rule.
Why fetch the URL instead of pasting?
Claude Design's prompt-injection defences refuse pasted text that reads
like third-party "you must do X" rules. Asking the tool to fetch the URL
in your own voice ("please follow this as my brief") frames the same
content as a designer-supplied brief, which the tool accepts. It also
means a single source of truth: the prompt deployed at {{PLATFORM_HOST}}
is the prompt every designer is currently working against.
Fallback: paste the prompt directly
If a designer can't get the design tool to fetch the URL (offline, network
blocked, tooling limitation), they can paste the contents of
docs/designer-system-prompt.md into the first message instead. Frame
the paste with their own words first ("here is my brief — please follow
it") rather than just pasting the raw markdown.
What you produce (and what we render)
| You author | What we keep | What we discard / transform |
|---|---|---|
Header.jsx … Footer.jsx (canonical filenames) | Both the DATA const AND the JSX function body — we render the JSX server-side to HTML at import time and serve that HTML verbatim. | Behaviour past the initial render (see sandbox note below). |
colors_and_type.css :root vars + base rules | The full file (typography, button, link, utility classes) is inlined at the top of the served stylesheet; theme tokens also become inline style vars on .gb-page. | (Nothing.) |
styles.css | Rules survive; url(assets/...) references get rewritten to absolute media URLs. | @import rules — except for trusted external hosts (Google Fonts, Bunny Fonts) which are kept so typography works. The @import url("colors_and_type.css") line is dropped because the file is inlined. |
chats/*.md | Stored as design notes. | (Nothing.) |
assets/*, uploads/* | Uploaded to S3, referenced by media ID. | Files outside these dirs. |
tweaks-panel.jsx | (Ignored — designer tooling.) | — |
index.html | Parsed for per-page composition — the App component's page === '<slug>' branches become real pages on the tenant site (see "Multi-page sites" below). | Everything outside the recognised App shape (e.g. helper JSX, scaffolding markup). |
Any other .jsx in project/ | Rejected — the importer fails the bundle. | — |
Sandbox contract — TWO render passes:
Every block JSX file is rendered server-side at import time (the captured HTML is what crawlers and no-JS visitors see) AND bundled into the per-tenant client JS bundle that runs in the browser on
DOMContentLoaded. The client mount replaces the server-rendered tree with a live React tree (re-render strategy — not hydration).
- SSR (import-time): hooks are stubbed so the initial render captures
useState's initial value, no effects fire, no setters trigger re-renders. Browser globals (window,document, etc.) are stub objects. This pass exists to produce a reasonable initial paint, NOT to be the entire experience.- Runtime (browser): hooks behave normally.
useState,useEffect,useRef,useMemoall work. The samesetInterval-driven autoplay carousel that captured a single static frame at import time now actually rotates in the browser. Click handlers attached viaonClickfire.setInterval/setTimeoutwork insideuseEffect.- Per-block error boundary: if a block component throws on client mount (e.g. references an undefined property), the bootstrap restores the server-rendered HTML for that block region and logs the failure to the browser console. Other blocks mount normally.
- Don't reference
windowat module-load time. InsideuseEffectis fine, inside event handlers is fine. Pattern that breaks:useState(window.matchMedia(...).matches)— SSR capturesundefinedthen the client mount captures the real value, but the captured HTML is still wrong. Pattern that works: read the value insideuseEffectand call the setter.window.X = XIIFE export still works. The platform's client bootstrap discovers block components viawindow.<BlockName>.<a href>and<button onClick>are both fine. Pick the semantic one.<a href="/services">is rewritten to#servicesand lands on the matching block anchor;<button onClick>runs in the browser.- No npm imports. Block files must be self-contained — the only modules resolvable at runtime are
react,react-dom/client, andreact/jsx-runtime, served by the platform and externalised from the per-tenant bundle. Barereact-domis intentionally NOT mapped (its API surface —flushSync,createPortal, server entry-points — differs fromreact-dom/client); usereact-dom/clientforcreateRoot.- Bundle size budget: 10 KB minified per block file, 200 KB total. The total minified size lands in the import audit log as
bundleBytes; operators monitor the 200 KB total budget against that field. Per-block measurement is a follow-on once per-block code splitting lands.
Services and products may be collection-backed (catalogue change onwards). From the
tenant-admin-cataloguechange forward, tenants edit services and products in the admin shell as Payload collection rows, and the renderer can read them from there. Your block JSX can still inline a static array — that remains the source of truth for what gets imported — but if you intentionally omit the inline list, the renderer falls back to the collection. Pick whichever fits the design: a single-shot static showcase is easier to inline; a tenant who keeps tweaking the menu wants the collection.
Products can also carry a single image via the
productscollection (tenant-admin-catalogue-crudchange onwards). When a tenant attaches an image to a collection product, the Gallery's by-section view groups it under a synthetic "Products" section. Re-importing the handoff bundle re-asserts the inlineProducts.jsximagery as the source of truth — admin-attachedproducts.imagereferences are preserved on the collection row, but the block'sdata.products[].imagereverts to whatever the bundle declares.
AboutPanelstats are editable when you express them as data (contract 2.3.0 onwards). The end-of-panel figures (e.g.15+ / years advising) become tenant-editable typed fields only if you author them as an optionalDATA.statsarray of{ value, label }and render the row withDATA.stats.map(...). Hardcode them as inline<div>markup and they still render, but the tenant can't change them without a re-import. Omit the key if the design has no stats row — it's optional and uncapped.
Naming things
- Filenames are case-sensitive.
hero.jsxis rejected;Hero.jsxis the only acceptable form. - The content constant must be named
DATAexactly.data,CONTENT,propsare rejected. - The schema for each
DATAis inHANDOFF_CONTRACT.md. Stick to it — extra keys and missing required keys both fail.
Content-bearing blocks: 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>"> so the platform can swap the inner
content for live data at request time. The wrapping element preserves
your section chrome — heading, grid wrapper, padding — and only the
inner children are replaced.
| Block file | Required 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"> |
Example for Gallery.jsx:
function Gallery() {
return (
<section className="gb-gallery">
<h2>{DATA.heading}</h2>
<div data-gb-slot="gallery">
{/* Preview tiles, replaced at render time. */}
{DATA.images.map((i) => (
<figure key={i.image}>
<img src={i.image} alt={i.alt} />
</figure>
))}
</div>
</section>
)
}
The preview tiles you ship are a sizing/layout reference at handoff time — they are never shown to a visitor. From v2.1.0 the platform also captures your per-item JSX as a template (by rendering your block a second time with a single sentinel item) and clones it for each live row, so your per-item CSS classes, hover animations, and inner markup are preserved exactly. The cost of this for you: your block must render correctly when DATA has a single-item array (or two items for Testimonial — needed for the dots-row template). Avoid:
{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
Always:
{
DATA.images.map((t, o) => <figure key={o}>...</figure>)
} // correct
Quotes in Testimonial.jsx may carry an optional rating: 1-5. If
omitted, the platform defaults to 5 stars. The hardcoded ★★★★★
row inside your slot div is captured as part of the template and
filled per-quote at render time.
The live render reads from the corresponding Payload collection or tenant field:
Gallery.jsx→ Gallery block-instance placements +media.altProducts.jsx→productscollection (status: 'live')Testimonial.jsx→testimonialscollection (status: 'live')ContactInfo.jsx→tenants.publicContactFooter.jsx→tenants.socialLinks
First-import vs re-import
- First import seeds the corresponding Payload collection/field
from your block's
DATAif it's currently empty. - Re-imports are additive: new entries land with
status: 'off'for tenant review; existing rows are never modified. Removals from your bundle are ignored. Tenant edits to content are sovereign — once a tenant has populated their products / testimonials / contact / social links, no re-import will overwrite them.
This is intentional. As a designer your role is to ship a complete sample of the live site on day 1, then move on. Subsequent design iterations refresh layout and theme, never content.
The BookForm placeholder
Do not design your own booking UI. The book page is served by the
platform's live multi-step booking widget — a "Step 1 of 4" indicator,
session cards, a time picker, a details form, and a confirmation screen
— and it inherits your theme tokens (--brand-*) automatically. Your
BookForm.jsx is a placeholder that the importer replaces wholesale
at render time; it exists only so your book page previews faithfully
in the design tool.
Use the canonical BookForm.jsx published in the system prompt
({{PLATFORM_HOST}}/system-design-prompt.txt, section 4, also reproduced
in HANDOFF_CONTRACT.md → "Canonical BookForm.jsx placeholder").
Copy it verbatim and edit only the DATA prose to match the tenant.
It already mocks the real widget — step dots, session cards from
DATA.sessions, a primary Continue action, and a "Booking by Get
Bookable" footer — so don't restyle it; the production widget owns the
look. The live widget renders as a full-width hero band (heading +
eyebrow / lead) with a "what to expect" aside beside the card,
not a compact inline form — design the sections above and below /book
against that taller footprint so the imported page matches your design.
What the importer does with each DATA key:
DATA key | Fate |
|---|---|
eyebrow, heading, lead, location | Kept — lifted onto the BookForm block instance (page-frame copy) |
labels.* | Kept — the widget's in-chrome field copy, read verbatim |
aside.{title, points, contactLabel, email} | Kept — the "what to expect" narrative beside the widget |
sessions[] | Consumed for services seeding only (each needs a name) |
| The JSX function body | Discarded — swapped for the live widget at render time |
days, slotsByDay, taken (if present) | Discarded — design-time mock data |
Use these exact labels keys: name, phone, email, service,
preferredDay, timeOfDay, notes, submit. If you want the booking
page to feel different visually, do it through your theme tokens in
colors_and_type.css — the widget consumes the same CSS variables your
stylesheet emits.
Multi-page sites
A tenant site is more than just one long scrolling page. The importer
reads project/index.html to find out which blocks belong on which
page, and creates a real URL per page on the tenant site.
How composition works
In index.html, write an App component whose JSX returns a fragment
with one branch per page, gated on a page === '<slug>' check.
Header and Footer go outside the branches because they're
tenant-wide:
function App() {
const [page, setPage] = React.useState('home')
return (
<>
<Header />
{page === 'home' && (
<>
<Hero />
<ServiceList />
<AboutPanel />
<Testimonial />
</>
)}
{page === 'services' && (
<>
<ServiceList />
<PricingTable />
<FAQ />
</>
)}
{page === 'products' && <Products />}
{page === 'gallery' && <Gallery />}
{page === 'about' && <AboutPanel />}
{page === 'book' && <BookForm />}
<Footer />
</>
)
}
const App = () => { ... } works equivalently. The full recognised
shape is documented in HANDOFF_CONTRACT.md under
"index.html as the page router".
Slugs and URLs
Each slug becomes a real URL on the tenant site:
home→/services→/servicesproducts→/productsgallery→/galleryabout→/aboutbook→/book
Custom slugs work — write {page === 'menu' && (<>…</>)} and it'll be
served at /menu. Slugs 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.
Branches whose slug contains a slash, a space, a leading dot, or any
other character are silently dropped at parse time. Keep slugs short
and lowercase; use a hyphen if you need multiple words.
Navigation between pages is a real page load
Crossing between pages is a full-page reload, not SPA routing. This is deliberate — every page lands with the right initial HTML for crawlers, no-JS visitors, and accessibility tools, and we don't ship a router on the client. Practically:
<a href="/services">in yourHeader.jsxis a real cross-page link to theservicespage whenservicesis one of your bundle's page slugs. The browser performs a normal navigation.<a href="#about">(fragment anchors) still work for in-page jumps within a page — useful for "jump to the contact section on the home page".- If you write
<a href="/menu">butmenuisn't a page slug in your bundle, the importer treats it as a same-page fragment anchor fallback (#menu) instead of producing a broken cross-page link.
Fallback if the parser can't read your App
If index.html is missing, has no <script type="text/babel"> body,
or has a body whose App shape doesn't match what the importer
recognises (e.g. uses || instead of &&, or
page.includes(...) instead of page === ...), the import still
succeeds — but the platform falls back to the legacy single-page
render: every block file is concatenated onto / in canonical order,
and there are no per-page URLs. If you're expecting multiple pages and
you only see /, check the import audit log for an
indexHtmlParsed: false row.
Iterating after the first launch
When you revise a tenant's design after launch:
- Same filenames, same shapes — that's the contract.
- The platform's re-import logic preserves tenant-edited fields where the matching block survives (matched by block type + position).
- If you renamed a field on a block, the rename behaves like "field removed in new design" — the tenant's edited value is dropped. Coordinate with the operator before doing that.
- Theme tokens, the full stylesheet, and chat transcripts are always replaced — the new design owns them.
Common rejections and what they mean
| Error | Cause | Fix |
|---|---|---|
Filename "Banner.jsx" is not in the Handoff Contract block list | You invented a block type. | Either rename the file to a canonical type, or split the content across existing types. |
Missing required top-level "const DATA = { … }" declaration | Content lives in the function, not in DATA. | Lift it to a module-level const DATA = { … }. |
Identifier "x" is not allowed in DATA | A DATA value references another variable. | Inline the value. The importer is a literal extractor, not an evaluator. |
Missing required theme token(s): --brand-ink | A required CSS variable wasn't declared on :root. | Add it to colors_and_type.css. |
Referenced asset not present in bundle | DATA points at an asset path that wasn't bundled. | Make sure the file is in assets/ or uploads/ and the path in DATA matches. |
Useful invariants you can rely on
- The tenant cannot change layout. They can edit text, swap images, reorder list items within a block, and not much else.
- The theme tokens you declare are the only knobs that survive into
production CSS variables. If something needs to vary per tenant,
bind it to a
--brand-*or similar token. HeaderandFooterare tenant-level, applied to every page. Design them once.- All asset paths in
DATAare rewritten to internal media IDs at import time. Once imported, the tenant could move the asset to a different bucket location without breaking references.