Get Bookable monogramGet Bookable
DocsSign in

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 authorWhat we keepWhat we discard / transform
Header.jsxFooter.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 rulesThe 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.cssRules 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/*.mdStored as design notes.(Nothing.)
assets/*, uploads/*Uploaded to S3, referenced by media ID.Files outside these dirs.
tweaks-panel.jsx(Ignored — designer tooling.)
index.htmlParsed 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, useMemo all work. The same setInterval-driven autoplay carousel that captured a single static frame at import time now actually rotates in the browser. Click handlers attached via onClick fire. setInterval / setTimeout work inside useEffect.
  • 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 window at module-load time. Inside useEffect is fine, inside event handlers is fine. Pattern that breaks: useState(window.matchMedia(...).matches) — SSR captures undefined then the client mount captures the real value, but the captured HTML is still wrong. Pattern that works: read the value inside useEffect and call the setter.
  • window.X = X IIFE export still works. The platform's client bootstrap discovers block components via window.<BlockName>.
  • <a href> and <button onClick> are both fine. Pick the semantic one. <a href="/services"> is rewritten to #services and 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, and react/jsx-runtime, served by the platform and externalised from the per-tenant bundle. Bare react-dom is intentionally NOT mapped (its API surface — flushSync, createPortal, server entry-points — differs from react-dom/client); use react-dom/client for createRoot.
  • 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-catalogue change 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 products collection (tenant-admin-catalogue-crud change 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 inline Products.jsx imagery as the source of truth — admin-attached products.image references are preserved on the collection row, but the block's data.products[].image reverts to whatever the bundle declares.

AboutPanel stats 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 optional DATA.stats array of { value, label } and render the row with DATA.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.jsx is rejected; Hero.jsx is the only acceptable form.
  • The content constant must be named DATA exactly. data, CONTENT, props are rejected.
  • The schema for each DATA is in HANDOFF_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 fileRequired 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.alt
  • Products.jsxproducts collection (status: 'live')
  • Testimonial.jsxtestimonials collection (status: 'live')
  • ContactInfo.jsxtenants.publicContact
  • Footer.jsxtenants.socialLinks

First-import vs re-import

  • First import seeds the corresponding Payload collection/field from your block's DATA if 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 keyFate
eyebrow, heading, lead, locationKept — 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 bodyDiscarded — 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/services
  • products/products
  • gallery/gallery
  • about/about
  • book/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.

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 your Header.jsx is a real cross-page link to the services page when services is 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"> but menu isn'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

ErrorCauseFix
Filename "Banner.jsx" is not in the Handoff Contract block listYou 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 = { … }" declarationContent lives in the function, not in DATA.Lift it to a module-level const DATA = { … }.
Identifier "x" is not allowed in DATAA DATA value references another variable.Inline the value. The importer is a literal extractor, not an evaluator.
Missing required theme token(s): --brand-inkA required CSS variable wasn't declared on :root.Add it to colors_and_type.css.
Referenced asset not present in bundleDATA 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.
  • Header and Footer are tenant-level, applied to every page. Design them once.
  • All asset paths in DATA are rewritten to internal media IDs at import time. Once imported, the tenant could move the asset to a different bucket location without breaking references.