SpectreSkills
← All skills

cms-theme-contract

v1.0.0

Model a visual CMS as a Shopify OS 2.0-style theme contract — a ThemeBundle of primitive settings, JSON templates, and section instances, normalized on every read, in a zero-dependency package both the editor and the public renderer import. Trigger when building or extending a page builder, theme engine, or customizer, or when deciding what lives in the bundle versus beside it.

CMS & Page Builderscmsthemeschemacontract
Install
npx @spectre-apps/skills add cms-theme-contract

Writes .claude/skills/cms-theme-contract/SKILL.md. Add --user to install globally.

What it does

The theme contract

A theme is one JSON document — the bundle — and nothing else. Everything the customizer edits, the public renderer paints, and a publish freezes is derived from that document. Get this shape right first: the catalog, the pages table, the canvas, and the seam are all downstream of it, and a bundle that cannot be parsed is a site that silently stops rendering.

The mental model is Shopify Online Store 2.0, ported to a headless React renderer:

Shopify OS 2.0Here
Theme (live + unpublished library)theme rows; exactly one published per workspace
settings_data.jsonThemeBundle.settings — design tokens, SEO, brand
JSON templatesThemeBundle.templates.{index,page,…}
Sections + {% schema %}SectionManifest in code + SectionInstance in JSON
BlocksBlockInstance nested in a section
Header/footer section groupsThemeBundle.sectionGroups.{header,footer}
Theme editora Customizer over a live preview iframe

The one place the model breaks from Shopify is pages. A page is Content, not Theme — it lives in its own table, shared by every theme. See cms-pages-and-templates. Do not put pages back in the bundle to make the metaphor tidier.

Every setting value is a primitive

type SettingValue = string | number | boolean | null;

Complex values (a menu tree, a link list) are JSON-encoded into a string and parsed by the field's reader. Persistence, validation, and DTO mapping never have to widen. This is why a bundle can live for a year without a migration. Do not add an object arm — the moment settings hold nested objects, every writer and every reader grows a shape they will disagree on.

Normalize on every read — never migrate

A bundle lives in three places at once: the theme's working draft, every published version, and every rollback snapshot. You cannot migrate all three, and a bundle that fails to parse is a site that 500s.

export function normalizeBundle(raw: unknown): ThemeBundle {
  // coerce legacy shapes forward, forever
}

Run it on every read, not as a one-time migration. Detection is on the VALUE (does this look like the old shape?), so it is idempotent. Legacy shapes coerce forward until the next save persists them. Widening a field is free; renaming one costs a coerce forever — which is cheaper than rewriting append-only history.

The contract package takes no dependencies

Put the types, the reconciler, the CSS compiler, and the preview-message union in a package that imports nothing — no React, no node:, no IO, no runtime deps. Both the editor and the public renderer import it. A dependency added there becomes a dependency of the public site, which is the surface you most want small and cacheable.

Enforce the empty dependencies block with a test. The same test is what lets you claim the editor and the renderer share every resolver by construction rather than by discipline.

Section type is globally unique

One flat component registry, keyed by type. Rendering needs no brand or tenant context. Catalog independence is an owner tag on the manifest (and a filter at every enumeration surface), not a second type namespace.

A customer-imported section is namespaced custom:<id> so it can never shadow a first-party type — for them or for anyone else on the deployment. See cms-imported-sections.

The reconciler is declarative

Bundle surgery (add section, swap anchor, ensure chrome) returns the new bundle plus warnings[]. A programmatic caller self-corrects instead of failing. Fixed sections that a page cannot do without are inserted when missing; extras in an anchorGroup are dropped, keeping the first.

Never throw from a reconcile because a draft is half-built. The customizer saves drafts. A schema that only accepts finished bundles makes a freshly dropped section unsaveable, which makes the editor unusable.

What does not live in the bundle

  • Pages — workspace-scoped rows. The theme supplies only the layout each page renders through.
  • Media bytes — settings store resolved URLs. Deleting a library row must not break a theme that already used the file.
  • Membership, domains, billing — the workspace, not the theme.

If you are about to add a new top-level key to the bundle, ask whether a rollback should restore last week's value. If no, it is Content, and it belongs beside the bundle, not in it.