Skip to content
pcreative Commerce

Theme contract — pcreative Commerce

The standard every storefront theme meets so they are interchangeable, customisable from the panel and updatable without losing the customer's…

Version 2.0 · replaces 1.0 (themes tied to Next.js/React).

The standard every storefront theme meets so they are interchangeable, customisable from the panel and updatable without losing the customer's configuration, without giving up each theme having a 100% bespoke design.

Reference implementation: the @pcreative/theme-contract package. Reference theme: mascotas, one of the storefronts that ship with the store.


What changed from 1.0, and why

1.0 pinned the stack: "Next.js 15 + React 19 + Tailwind 4 + the engine's SDK". It worked, but it turned the theme catalogue into "just another React template", and there it competes with Vercel Commerce, which is free. The backend is headless: what tied it to React was not the system, it was two TypeScript files and a wrapper.

1.02.0
theme.config.ts (TypeScript)theme.json + settings.schema.json + settings.json (JSON)
tokens in a TS objecttokens.json in W3C/DTCG format
theme-tokens.ts generates CSS by handthe contract generates it, and Style Dictionary compiles it to CSS/SCSS/JS/iOS/Android
lib/commerce.ts wrapping the engine's SDKdata contract over the Store API + per-backend adapter
theme.override.ts (code)theme.override.json (data)
customiser form hardcoded in the admineach theme declares its settings; the panel generates the form

Practical consequence: a theme can be written in Next, Astro, Nuxt, SvelteKit, Remix/React Router, Vue or Eleventy, and the same panel installs it, customises it and deploys it. That is what neither Vercel Commerce (a single React storefront) nor WooCommerce/PrestaShop (thousands of themes, but tied to PHP) has. PHP themes are not run: see which stacks run.


1. Structure of a theme package

themes/<id>/
├── theme.json              manifest: identity, runtime, what it implements
├── tokens.json             design tokens in DTCG format
├── settings.schema.json    what can be touched (defines the customiser)
├── settings.json           the theme's factory values (the demo)
├── sections.schema.json    which sections it knows how to render
├── templates/*.json        how its pages are composed out of the box
├── locales/                its copy: <lang>.json (storefront) and <lang>.schema.json (customiser)
├── demo/contenido.json     sample content
├── theme.override.json     ← written by the panel, PER STORE (not shipped by the theme)
├── preview.png
└── src/                    FREE: the theme's code, in whatever stack

The JSON files are the contract. src/ is the theme's territory: neither the panel nor the installer look in there.

The rule that holds up everything else: customising never edits the theme, only theme.override.json. That is why updating a theme to a new version does not overwrite the customer's brand or colours — which is exactly what breaks in any system where "customise" means touching the theme's own files.


2. theme.json — the manifest

Schema: schema/theme.schema.json, inside the @pcreative/theme-contract package.

{
  "contract": "2.0",
  "id": "my-theme",                  // kebab-case, unique in the catalogue
  "name": "My Theme",                // what the panel shows
  "version": "1.2.0",
  "author": "Your studio",
  "license": "Proprietary",
  "description": "Pet shop. Browse by animal, not by category.",
  "niche": ["pets", "petshop"],       // filters the catalogue (max. 12)
  "preview": "preview.png",
  "screenshots": ["screenshots/home.png", "screenshots/product.png"],
  "demo": "demo/contenido.json",     // default value

  "runtime": {                       // ← the field that makes the contract agnostic
    "target": "ssr",                 // ssr | static | spa  (the schema also accepts php | native)
    "stack": "next",                 // next | astro | nuxt | sveltekit | remix | react-router | vite-react | vue | eleventy
    "engine": "node>=20",
    "packageManager": "npm",         // npm | pnpm | yarn | bun
    "commands": { "install": "npm ci", "build": "next build", "start": "next start" },  // informative only
    "env": [
      { "name": "COMMERCE_URL", "required": true, "example": "http://localhost:9000" },
      { "name": "COMMERCE_KEY", "required": true }
    ],
    "hosts": ["cdn.example.com", "fonts.gstatic.com"]  // outside domains the code talks to
  },

  "capabilities": {                  // what it implements: the panel warns before activating it
    "pages":    ["home", "catalog", "product", "cart", "checkout", "..."],
    "blocks":   ["hero", "featured-products", "testimonials"],
    "features": ["search", "server-pagination", "structured-data"],
    "locales":  ["en", "es"],        // the first one is the base language
    "a11y":     "wcag-aa"
  },

  "commerce": { "contract": "1.0", "adapters": [] },

  "tokens": "tokens.json",           // default value
  "settings": { "schema": "settings.schema.json", "defaults": "settings.json" },  // default values

  "extiende": "base",                // optional: inherit from another theme (see below)
  "migraciones": [                   // optional: what happened to each setting between versions
    { "desde": "2", "renombra": { "design.accent": "design.primary" }, "elimina": ["design.oldFont"] }
  ],
  "license_check": { "product": "my-theme", "gracia": 7 }  // only for themes sold under licence
}
FieldWhat it is
id / nameThe identifier (kebab-case, unique) and the name the panel shows. They are different things: mascotas is the id, "Mascotas" the name.
niche, preview, screenshots, descriptionCatalogue data: filtering and the theme's card.
demoPath to the sample content, inside the theme. Default demo/contenido.json.
runtime.hostsOutside domains the theme needs (image CDN, fonts…). The audit warns about any domain the code talks to that is not declared here.
tokens, settingsPaths to the design and customiser files, if they are not the default ones.
extiendeId of the parent theme. The child only carries what changes; tokens, settings, sections and templates come from the parent, merged by key. The identity (id, name, version) is never inherited.
migracionesA list of changes per major version (desde): renombra (old → new, the old one is kept), elimina (warned, not deleted) and anade (new settings with a value, only if missing). It is data, not a script: updating never runs the theme's code.
license_checkOnly for themes sold under licence: product, optional endpoint (licence server, default pcreative's) and gracia, the days it keeps working when the licence server does not answer — from 1 to 90, default 7. The storefront is never switched off because of it. Free themes omit it.

Which stacks run. The schema leaves stack as an open list, but the panel only installs and deploys the ones it knows how to run: next, astro, nuxt, sveltekit, remix, react-router, vite-react, vue and eleventy. With any other stack (Laravel or anything PHP, for example) the theme can validate, but the panel rejects it on install with "unsupported stack". The same goes for packageManager: npm, pnpm, yarn and bun. With target static or spa the theme is built and served as files, with no process of its own.

runtime.commands is informative only. It documents how the theme is worked on by hand, but it is not what runs: the panel decides the real commands from stack and packageManager (installing with scripts disabled). If a theme could dictate the command, installing it would mean running whatever its author wanted. pcc-theme audit shows exactly what would run.


3. tokens.json — design in a standard format

Format: Design Tokens Format Module (DTCG), the W3C Community Group standard that reached stability in 2025 and is already supported by Figma, Style Dictionary, Tokens Studio, Penpot and Terrazzo. There is nothing to invent here: a designer exports from Figma and the theme eats it.

{
  "color": {
    "$type": "color",
    "brand": {
      "primary": {
        "$value": { "colorSpace": "srgb", "components": [0.0863, 0.6392, 0.2902], "hex": "#16a34a" }
      }
    },
    "scale": {
      "leaf-400": {
        "$value": "{color.brand.primary}",
        "$extensions": {
          "dev.pcreative.derive": { "from": "{color.brand.primary}", "lighten": 0.24 },
          "dev.pcreative.css": "--color-leaf-400"
        }
      }
    }
  }
}

Three of our own extensions, all inside $extensions as the spec requires:

ExtensionFor
dev.pcreative.deriveDerives one colour from another by lightening/darkening it. It is what lets changing one colour in the panel repaint the whole scale: a CSS variable does not know how to recalculate itself.
dev.pcreative.cssFixes the name of the emitted variable. Useful for adopting the contract in an existing theme without touching a single component.
dev.pcreative.privateThe token exists but is not emitted as a CSS variable.

How the tokens reach the theme

Variable names are derived from the path: color.brand.primary → --color-brand-primary (identical to Style Dictionary's name/kebab, so the build and the customiser do not contradict each other).

  • Live (customiser): the contract's tokensToCss() — no dependencies, runs in Node, on the edge and in the browser. The panel saves the override, the storefront injects the CSS and the site repaints with no rebuild.
  • On build (distribution): a Style Dictionary v5 preset, which understands DTCG out of the box — colour-as-object included — and outputs CSS, SCSS, JS and, if it is ever needed, iOS and Android.

4. settings.schema.json — the customiser, declared by the theme

Schema: schema/settings.schema.json, inside the contract package. The approach is Shopify's settings_schema.json, with the types aligned to the tokens.

{
  "groups": [
    {
      "id": "design", "label": "Design", "icon": "palette",
      "settings": [
        { "type": "color", "id": "primary", "label": "Primary colour",
          "token": "color.brand.primary" },        // ← the design ↔ form bridge
        { "type": "select", "id": "mode", "label": "Mode", "default": "dark",
          "options": [{ "value": "dark", "label": "Dark" }] }
      ]
    },
    {
      "id": "commerce", "label": "Store",
      "settings": [
        { "type": "number", "id": "codSurcharge", "label": "Cash-on-delivery surcharge", "unit": "€",
          "visibleIf": { "setting": "commerce.paymentMethods", "equals": "cod" } }
      ]
    }
  ],
  "blocks": [
    { "type": "hero", "label": "Hero", "limit": 1,
      "settings": [{ "type": "text", "id": "heading", "label": "Heading", "required": true }] }
  ]
}

Field types: text, textarea, richtext, number, range, checkbox, select, radio, color, font, image, url, email, tel, list, token, plus header and paragraph for laying out the form.

A field with token is the only bridge between the form and the design: when the customer changes it, the value goes to the token, from there to the CSS variable, and from there to everything derived from it. That is why the panel does not need to know anything about the theme's stack to recolour it.


5. Pages and blocks

Standard routes (capabilities.pages), so the catalogue is predictable: home · catalog · category · product · search · cart · checkout · order-confirmation · account · legal · about · contact · faq · blog · blog-post · not-found.

Blocks are the composable sections. The theme declares which ones it can render; the override says in what order they go and with what settings:

{ "sections": { "home": [
  { "type": "hero", "settings": { "heading": "Indoor growing, done right" } },
  { "type": "bestsellers", "settings": { "limit": 8 } }
]}}

When the editor repaints a section

When a setting changes, the editor replaces that section's HTML and leaves the rest of the page alone. Whoever is editing keeps their scroll position and their cart, and the whole catalogue is not fetched again for one changed word.

That has two consequences that raise no error, and that you need to know.

1. JavaScript does not run again, and what ran before is not undone. The framework's lifecycles (onMount, useEffect, onMounted) do not fire: the HTML arrives already built. There are two events for this, dispatched on the section's node, and they bubble:

eventwhenwhat for
pcc:seccion:descargadabefore the old node is removedstop loops, remove observers, free WebGL
pcc:seccion:cargadaafter the new one is inmount carousels and animations again

Both carry detail: { id, el }. 🔴 If you do not listen to the unload event, every edit stacks another set of listeners and another animation loop on top of the last; after ten edits the page crawls.

2. Some sections cannot be painted on their own. A hero that pins the scroll of the whole page, a shared WebGL canvas, smooth scrolling or a timeline spanning several sections do not exist outside their page: replacing their node does not fail, it leaves something painted and broken. Declare it in the catalogue and the editor reloads the page for that section instead of replacing it:

{ "type": "hero-cinematografico", "name": "Cinematic hero", "aislable": false }

The default is true. It costs half a second more and shows the truth.


6. theme.override.json — the store's layer

Schema: schema/override.schema.json, inside the contract package.

{
  "contract": "2.0",
  "theme": "mascotas",
  "themeVersion": "2.0.0",
  "settings": { "brand": { "name": "Green Room" }, "design": { "primary": "#16a34a" } },
  "tokens":   { "color.surface.base": "#0d0f0e" },
  "sections": { "home": [ /* … */ ] }
}

It stores only what differs. The merge order is schema defaults ← the theme's settings.json ← the store's override, and the merge is per field, not per group: a partial override does not wipe the rest.


7. Data contract (commerce)

theme.json declares commerce.contract, not an SDK. A theme does not import the backend's SDK: it consumes the data contract and the adapter resolves it. That way the same theme serves the current engine today and another backend tomorrow, and — the other way round — the backend is not married to React themes.

What a theme can ask for, method by method: Commerce contract.


8. Tools

pcc-theme init     my-theme --name "My Theme" --stack astro   # new theme that already sells and validates
pcc-theme validate themes/mascotas   # shape (JSON Schema) + coherence between files
pcc-theme audit    themes/mascotas   # what would run, which domains it talks to, blockers
pcc-theme pack     themes/mascotas   # validates, audits, looks for secrets, builds and makes the .zip
pcc-theme sign     themes/mascotas --key private.pem --publisher "Your studio"
pcc-theme verify   themes/mascotas --pubkey public.pem
pcc-theme css      themes/mascotas   # CSS variables already resolved
pcc-theme info     themes/mascotas   # theme summary

init creates the whole theme (contract files, templates, locales, demo and code). It needs a folder or --id; --stack takes next, astro, sveltekit, nuxt, react-router and vite-react (default next).

validate does two passes. The shape pass validates each file against its JSON Schema. The coherence pass is the one that catches what no schema sees: a colour field pointing at a token that does not exist, a block the customiser offers and the theme does not implement, an override from a different version, a colour whose hex and components are not the same colour.

audit reviews the theme as the panel would before installing it: it prints the install, build and start commands that would really run, lists the outside domains the code talks to (warning about those not declared in runtime.hosts), and separates blockers (no lockfile, dangerous install scripts, high-severity findings in the code) from warnings. It exits with an error if there is any blocker.

pack goes through everything in order — contract, audit, which files go in, secrets, portable dependencies (file: paths to @pcreative/* are packed into vendor/), a real install and build — and writes <id>-<version>.zip (--salida to change the name). With --key and --publisher it also signs the package; --sin-construir skips the build.

sign signs the theme with your private key and writes the signature to theme.sig. It needs --key and --publisher together; --kid names the key (default 1). verify checks that signature against a public key, from a file (--pubkey) or from an address (--url), and if it does not match it lists which files were altered, which are extra and which are missing. That way whoever installs it knows the theme is the one you signed and nobody touched it afterwards.

Every command and its options: The commands.


9. Migrating a theme from 1.0

  1. theme.meta.json + the identity part of theme.config.ts → theme.json.
  2. tokens from theme.config.ts → tokens.json. To avoid touching components, each token carries dev.pcreative.css with the variable name they already used.
  3. The scales theme-tokens.ts computed in TypeScript → tokens with dev.pcreative.derive. Same function, but declared.
  4. The rest of theme.config.ts → settings.schema.json (the fields) and settings.json (the values).
  5. theme.override.ts → theme.override.json.
  6. lib/commerce.ts → data-contract adapter.
  7. pcc-theme validate until it comes out clean.

mascotas is migrated and serves as a template.