# Template gallery — design

**Date:** 2026-08-25
**Status:** approved through Section 2; Sections 3-5 awaiting first review

## The problem

A developer embedding the form builder today has to hand it a form. There is no
way to let their user pick a starting design first. `PRESETS` exists in
`src/builder/presets.ts` with seven starter forms and is exported as
`@creaditor/form-builder/templates`, but it is data only: no UI, and seven
designs is not a gallery.

This adds a third embeddable surface alongside the editor and the renderer: a
template gallery the developer drops in before the builder, so their user
chooses a design and the builder opens on it.

## Decisions taken

| Question | Decision |
|---|---|
| Where templates come from | Bundled in the package. No host endpoint, no catalog fetch. |
| What a card shows | A live mini-render of the real template JSON through the existing renderer. No thumbnail artwork. |
| Handoff to the editor | A separate element emitting `select`. The gallery never imports the editor. |
| Catalog size | 24-40 designs authored as part of this work. |
| Localization | Chrome and taxonomy labels in en/he. Form copy stays English. |
| Browsing | Industry drives the top tabs; purpose is a chip row beneath them; plus text search. |
| Card interaction | Hover reveals *Use this template* + *Preview*; Preview opens a full-size lightbox. |
| Photo hosting | Keep hotlinking Pexels, as `presets.ts` does today. |
| Packaging | Three ways, mirroring the editor: React export, custom element, CDN IIFE. |

## Section 1 — The catalog

New directory `src/catalog/`. `src/builder/presets.ts` is left untouched so
`@creaditor/form-builder/templates` keeps working for anyone already importing
it.

```ts
export type Industry =
  | 'ecommerce' | 'fashion' | 'food' | 'fitness'
  | 'realestate' | 'education' | 'services' | 'travel' | 'tech';

export type Purpose =
  | 'newsletter' | 'discount' | 'waitlist' | 'contact'
  | 'feedback' | 'leadmagnet' | 'event';

export interface FormTemplate {
  /** Stable id, e.g. 'fashion-first-order-discount'. Never reused. */
  key: string;
  /** Exactly one. Drives the purpose chip row. */
  purpose: Purpose;
  /** Several. A discount card suits fashion, food and fitness alike. */
  industries: Industry[];
  /** Which of the five PopupDesign layouts, for filtering and for tests. */
  design: PopupDesign;
  /** Fresh ids on every call, same contract as Preset.make. */
  make: (name?: string) => PopupModal;
}
```

There is deliberately no `image` field. The card renders the form, and the form
already carries `imageUrl`, so a thumbnail cannot drift from what it depicts.

### Why one purpose and several industries

Nine industries by seven purposes is 63 cells against a catalog of roughly 36
templates. Under strict intersection most cells would be empty and the gallery
would keep showing dead ends. Tagging each template with several industries
keeps every industry tab showing 8-12 cards while the catalog stays authorable.
The alternative, one industry per template, means either 63 templates or a lot
of empty states.

### File layout

```
src/catalog/
  index.ts        TEMPLATES, INDUSTRIES, PURPOSES, and the filter helper
  types.ts        FormTemplate, Industry, Purpose
  parts.ts        heading/text/email/submit/base builders, lifted from presets.ts
  templates/
    newsletter/*.ts
    discount/*.ts
    waitlist/*.ts
    contact/*.ts
    feedback/*.ts
    leadmagnet/*.ts
    event/*.ts
```

One template per file. With the shared builders in `parts.ts` a template file is
about twenty lines of actual content, which is what makes 36 of them
maintainable. Each `templates/<purpose>/index.ts` re-exports its directory, and
`src/catalog/index.ts` concatenates the seven.

`parts.ts` is a copy of the private helpers in `presets.ts`, not a shared import:
the catalog must be free to evolve its defaults without changing the behaviour of
the older `PRESETS` export.

### Labels and localization

`Industry` and `Purpose` labels get a column each in `src/builder/i18n/en.ts` and
`he.ts`, alongside the existing `presets` block. Template display names are read
from the form's own first heading item, so there is no third string table.

Form copy stays English in every template. A Hebrew user therefore sees Hebrew
tabs and chips over English, LTR form previews. This is a known and accepted
consequence of the localization decision, and the per-file catalog layout is what
would later let a `he` variant be added file by file rather than as a rewrite.

## Section 2 — The element and its public API

```html
<creaditor-form-templates lang="en" theme="light" industry="fashion">
</creaditor-form-templates>
```

| Surface | Members |
|---|---|
| Properties | `templates`, `industry`, `purpose`, `showBlank` |
| Attributes | `lang`, `theme`, `accent`, `accent-gradient`, `industry`, `purpose`, `show-blank` |
| Events | `select`, `preview` |

- `templates` — replaces the bundled catalog wholesale. The escape hatch for a
  host with its own designs; unset, the bundled catalog is used.
- `industry` / `purpose` — the active filters, readable and writable so a host
  can preselect from what it already knows about the merchant.
- `showBlank` — whether the "Start from scratch" card appears. Default true.
- `select` — `detail` is a **fresh `PopupModal`**, already built. The host's
  handler is one line: `editor.form = e.detail`.
- `preview` — `detail` is the template key, fired when the lightbox opens. For
  hosts that want to log what their users browse.

Boolean and object conventions follow `FormBuilderElement` exactly: objects and
functions are properties only; strings work as either, with the property winning;
`show-blank="false"` and `show-blank="0"` are the only values that read as false.

Rendered into a shadow root with styles injected as a `<style>` element. Popovers
and the preview lightbox render inline within that root, never through a
`document.body` portal.

### Packaging

| Entry | Contents |
|---|---|
| `./gallery` | React `<TemplateGallery>`, with `./gallery.css` beside it |
| `./gallery-element` | The custom element, self-registering on import |
| `dist-cdn/creaditor-form-templates.js` | IIFE exposing `CreaditorTemplates` |

The custom element imports `gallery.css?inline` and injects it into its shadow
root, exactly as `element.tsx` does with `builder.css`. The React entry cannot do
that, so it emits a real stylesheet the host imports, which exposes a build
wrinkle: `vite.lib.config.ts` currently renames *any* emitted `style.css` to
`editor.css` via `assetFileNames`, on the assumption that only one entry produces
CSS. With two, that hook has to key off the chunk that owns the asset and emit
`editor.css` and `gallery.css` separately. Getting this wrong is silent, the two
stylesheets collide under one name, so the bundle test in Section 4 asserts both
files exist and that `gallery.css` contains no editor selectors.

A fourth vite config, `vite.templates.config.ts`, modelled on
`vite.renderer.config.ts`, with `emptyOutDir: false` so it lands beside the two
existing CDN bundles. `build:all` gains a `build:cdn:templates` step.

The gallery bundle pulls in the renderer and the catalog but never the editor.
A chooser screen therefore costs roughly the renderer's 243KB rather than the
editor's 888KB. There is an enforcement test for this; see Section 4.

## Section 3 — Rendering the previews

Each card renders `<PopupContent popup={template.make()} preview />` inside a
fixed-size stage, wrapped in a scaling shell:

```
.stage        aspect-ratio 4/3, overflow hidden, the neutral surface
  .scaler     transform: scale(k), transform-origin: center
    .frame    width: <the form's own width>px — rendered at natural size
```

The form renders at its true width, then CSS `transform: scale()` shrinks it to
fit. Scaling rather than re-laying-out at card size is what keeps the preview
honest: type ratios, image crops and button proportions stay exactly as the
merchant's visitor will see them. `k` is computed once per card from the stage
width and the form's `width`, and recomputed by a single shared
`ResizeObserver` on the grid.

The preview is inert. A wrapper sets `pointer-events: none` on the frame, so no
field takes focus, no submit fires, and nothing inside a card can steal a tab
stop. `PopupContent`'s existing `preview` prop already blocks redirect
navigation. Clicks are handled by the card, not the form.

### Cost control

Thirty-six mounted React trees with photos is the real risk. Three measures:

1. **Lazy mount.** An `IntersectionObserver` mounts a card's `PopupContent` only
   when the card is near the viewport, and cards keep their stage box reserved
   so nothing reflows. Above-the-fold cards mount immediately.
2. **Memoized forms.** `make()` is called once per template per gallery mount and
   cached, so filtering and re-renders never rebuild form JSON.
3. **`loading="lazy"` on template photos**, and the Pexels `w=` parameter tuned
   down for the card stage. The lightbox requests the full-size photo.

Filtering changes which cards are in the DOM, not which are mounted: a card
leaving the filter unmounts, and the memo keeps remounting cheap.

### The lightbox

Opening a preview renders the same template at full size on a dimmed backdrop,
inside the shadow root, with a *Use this template* button and the template's
industry and purpose listed. Escape and a backdrop click close it. Focus is
trapped while open and restored to the originating card on close.

## Section 4 — Testing

The project runs `tests/*.test.ts` on Node's built-in runner via
`scripts/run-tests.mjs`, with no DOM. Tests therefore target the catalog and the
build, which is where the risk actually is at 36 templates:

| Test | Asserts |
|---|---|
| `catalog-validity` | Every template's `make()` output passes `validatePopup` with no errors. |
| `catalog-freshness` | Two `make()` calls on the same template return different ids at every level, and no id repeats within one form. |
| `catalog-keys` | Keys are unique, kebab-case, and match no other template. |
| `catalog-tags` | Every `purpose` and every `industries` entry is a member of its union, and no template has an empty `industries` array. |
| `catalog-coverage` | Every industry has at least six templates and every purpose at least three, so no tab or chip lands on an empty grid. |
| `catalog-design` | The declared `design` matches the `design` on the form `make()` returns, and any template whose design uses an image actually sets `imageUrl`. |
| `gallery-bundle` | Builds the lib entries and asserts the `./gallery-element` chunk graph never reaches `FormEditor`. This is the guard on the whole separation. |
| `gallery-css` | `lib/editor.css` and `lib/gallery.css` both exist after a build, and neither contains the other's selectors. Guards the `assetFileNames` collision described in Section 2. |

Interaction behaviour, hover, lightbox, keyboard, is verified by hand against a
new `/templates-demo.html` dev page, in the same way `renderer-demo.html` covers
the renderer today. Adding a DOM test framework is out of scope here.

## Section 5 — Files

**New**

```
src/catalog/{index,types,parts}.ts
src/catalog/templates/<purpose>/*.ts           ~36 files
src/gallery/TemplateGallery.tsx                the React component
src/gallery/{TemplateCard,PreviewLightbox,GalleryFilters}.tsx
src/gallery/gallery.css
src/gallery/index.ts                           the "./gallery" entry
src/gallery-element/{element,index}.ts         the "./gallery-element" entry
src/gallery/standalone.ts                      the CDN entry
vite.templates.config.ts
templates-demo.html
tests/catalog-*.test.ts, tests/gallery-bundle.test.ts
```

**Changed**

```
package.json          three exports, one script, one build:all step
vite.lib.config.ts    two lib entries
src/builder/i18n/{en,he}.ts   industry and purpose labels
README.md             a gallery section and the CDN table row
docs/EMBEDDING.md     the choose-then-edit flow
```

**Untouched:** `src/builder/presets.ts`, `src/renderer/**`, `src/schema/**`.
The gallery consumes the renderer's public API and adds nothing to it.

## Out of scope

- Hebrew form copy in templates.
- Migrating template photography off Pexels.
- A DOM test framework.
- Wiring the gallery into the editor element itself. The two stay separate; the
  host composes them.
- Host-supplied catalogs over the network. The `templates` property covers the
  in-page case, and that is enough until someone asks for more.

## Open question

The `he` chrome will sit over English, LTR previews. Accepted for this version.
Worth revisiting once the catalog exists and the mismatch can be looked at
rather than imagined.
