Contrato de tema — pcreative Commerce
Estándar que cumple todo tema de storefront para que sean intercambiables, personalizables desde el panel y actualizables sin perder la configuración del…
Versión 2.0 · sustituye a la 1.0 (temas atados a Next.js/React).
Estándar que cumple todo tema de storefront para que sean intercambiables, personalizables desde el panel y actualizables sin perder la configuración del cliente, sin renunciar a que cada tema tenga diseño 100 % propio.
Implementación de referencia: el paquete @pcreative/theme-contract.
Tema de referencia: mascotas, uno de los escaparates que vienen con la tienda.
Qué cambió respecto a la 1.0 y por qué
La 1.0 fijaba el stack: «Next.js 15 + React 19 + Tailwind 4 + el SDK del backend». Funcionaba, pero convertía el catálogo de temas en «una plantilla React más», y ahí compite con Vercel Commerce, que es gratis. El backend es headless: lo que ataba a React no era el sistema, eran dos ficheros TypeScript y una envoltura.
| 1.0 | 2.0 |
|---|---|
theme.config.ts (TypeScript) | theme.json + settings.schema.json + settings.json (JSON) |
| tokens en un objeto TS | tokens.json en formato W3C/DTCG |
theme-tokens.ts genera CSS a mano | el contrato lo genera, y Style Dictionary lo compila a CSS/SCSS/JS/iOS/Android |
lib/commerce.ts envolviendo el SDK del backend | contrato de datos sobre la API de la tienda + adaptador por backend |
theme.override.ts (código) | theme.override.json (datos) |
| formulario del customizer hardcodeado en el admin | cada tema declara sus ajustes; el panel genera el formulario |
Consecuencia práctica: un tema puede estar escrito en Next, Astro, Nuxt, SvelteKit, Remix/React Router, Vue o Eleventy, y el mismo panel lo instala, lo personaliza y lo despliega. Eso es lo que no tiene ni Vercel Commerce (un solo storefront React) ni WooCommerce/PrestaShop (miles de temas, pero atados a PHP). Los temas en PHP no se ejecutan: ver qué stacks corren.
1. Estructura de un paquete de tema
themes/<id>/
├── theme.json manifiesto: identidad, runtime, qué implementa
├── tokens.json design tokens en formato DTCG
├── settings.schema.json qué se le puede tocar (define el customizer)
├── settings.json valores de fábrica del tema (la demo)
├── sections.schema.json qué secciones sabe pintar
├── templates/*.json cómo se componen sus páginas de fábrica
├── locales/ sus textos: <idioma>.json (escaparate) y <idioma>.schema.json (customizer)
├── demo/contenido.json contenido de ejemplo
├── theme.override.json ← lo escribe el panel, POR TIENDA (no lo trae el tema)
├── preview.png
└── src/ LIBRE: el código del tema, en el stack que seaLos JSON son el contrato. src/ es territorio del tema: ni el panel ni el
instalador miran ahí.
Regla que sostiene todo lo demás: personalizar nunca edita el tema, solo
theme.override.json. Por eso actualizar un tema a una versión nueva no pisa la
marca ni los colores del cliente — que es exactamente lo que se rompe en
cualquier sistema donde «personalizar» significa tocar los ficheros del tema.
2. theme.json — el manifiesto
Schema: schema/theme.schema.json, dentro del paquete
@pcreative/theme-contract.
{
"contract": "2.0",
"id": "mi-tema", // kebab-case, único en el catálogo
"name": "Mi Tema", // lo que enseña el panel
"version": "1.2.0",
"author": "Tu estudio",
"license": "Propietaria",
"description": "Tienda de mascotas. Se compra por animal, no por categoría.",
"niche": ["mascotas", "petshop"], // filtra el catálogo (máx. 12)
"preview": "preview.png",
"screenshots": ["capturas/inicio.png", "capturas/producto.png"],
"demo": "demo/contenido.json", // valor por defecto
"runtime": { // ← el campo que hace agnóstico el contrato
"target": "ssr", // ssr | static | spa (el schema admite también 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" }, // solo informativo
"env": [
{ "name": "COMMERCE_URL", "required": true, "example": "http://localhost:9000" },
{ "name": "COMMERCE_KEY", "required": true }
],
"hosts": ["cdn.ejemplo.com", "fonts.gstatic.com"] // dominios de fuera con los que habla el código
},
"capabilities": { // qué implementa: el panel avisa antes de activarlo
"pages": ["home", "catalog", "product", "cart", "checkout", "..."],
"blocks": ["hero", "featured-products", "testimonials"],
"features": ["search", "server-pagination", "structured-data"],
"locales": ["en", "es"], // el primero es el idioma base
"a11y": "wcag-aa"
},
"commerce": { "contract": "1.0", "adapters": [] },
"tokens": "tokens.json", // valor por defecto
"settings": { "schema": "settings.schema.json", "defaults": "settings.json" }, // valores por defecto
"extiende": "base", // opcional: hereda de otro tema (ver abajo)
"migraciones": [ // opcional: qué le pasó a cada ajuste entre versiones
{ "desde": "2", "renombra": { "design.accent": "design.primary" }, "elimina": ["design.oldFont"] }
],
"license_check": { "product": "mi-tema", "gracia": 7 } // solo en temas vendidos con licencia
}| Campo | Qué es |
|---|---|
id / name | El identificador (kebab-case, único) y el nombre que enseña el panel. Son cosas distintas: mascotas es el id, «Mascotas» el nombre. |
niche, preview, screenshots, description | Datos del catálogo: filtrar y la ficha del tema. |
demo | Ruta al contenido de ejemplo, dentro del tema. Por defecto demo/contenido.json. |
runtime.hosts | Dominios de fuera que el tema necesita (CDN de imágenes, fuentes…). La auditoría avisa de cualquier dominio con el que hable el código y que no esté declarado aquí. |
tokens, settings | Rutas de los ficheros de diseño y del customizer, si no son las de por defecto. |
extiende | Id del tema padre. El hijo solo lleva lo que cambia; tokens, ajustes, secciones y plantillas salen del padre, fusionados por clave. La identidad (id, name, version) nunca se hereda. |
migraciones | Lista de cambios por versión mayor (desde): renombra (viejo → nuevo, el viejo se conserva), elimina (se avisa, no se borra) y anade (ajustes nuevos con su valor, solo si faltan). Son datos, no un guion: actualizar nunca ejecuta código del tema. |
license_check | Solo en temas vendidos con licencia: product, endpoint opcional (servidor de licencias, por defecto el de pcreative) y gracia, los días que sigue funcionando cuando el servidor de licencias no contesta — de 1 a 90, por defecto 7. El escaparate nunca se apaga por esto. Los gratuitos lo omiten. |
Qué stacks corren. El schema deja stack como lista abierta, pero el panel
solo instala y despliega los que sabe ejecutar: next, astro, nuxt,
sveltekit, remix, react-router, vite-react, vue y eleventy. Con
cualquier otro (Laravel o cualquier cosa en PHP, por ejemplo) el tema puede
validar, pero el panel lo rechaza al instalarlo con «stack no soportado». Lo
mismo con packageManager: npm, pnpm, yarn y bun. Con target static
o spa el tema se construye y se sirve como ficheros, sin proceso propio.
runtime.commands es solo informativo. Documenta cómo se trabaja el tema a
mano, pero no es lo que se ejecuta: los comandos reales los decide el panel a
partir de stack y packageManager (instalando con los scripts desactivados).
Si un tema pudiera dictar la orden, instalarlo sería ejecutar lo que quisiera su
autor. pcc-theme audit enseña exactamente qué se ejecutaría.
3. tokens.json — diseño en formato estándar
Formato: Design Tokens Format Module (DTCG), el estándar del W3C Community Group que en 2025 alcanzó estabilidad y que ya soportan Figma, Style Dictionary, Tokens Studio, Penpot y Terrazzo. No hay nada que inventar aquí: un diseñador exporta de Figma y el tema se lo come.
{
"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"
}
}
}
}
}Tres extensiones propias, todas dentro de $extensions como manda la spec:
| Extensión | Para qué |
|---|---|
dev.pcreative.derive | Deriva un color de otro aclarándolo/oscureciéndolo. Es lo que permite que cambiar un color en el panel repinte la escala entera: una variable CSS no sabe recalcularse sola. |
dev.pcreative.css | Fija el nombre de la variable emitida. Sirve para adoptar el contrato en un tema que ya existe sin tocar un solo componente. |
dev.pcreative.private | El token existe pero no se emite como variable CSS. |
Cómo llegan los tokens al tema
Los nombres de variable se derivan del path: color.brand.primary →
--color-brand-primary (idéntico a name/kebab de Style Dictionary, así el
build y el customizer no se contradicen).
- En vivo (customizer):
tokensToCss()del contrato — sin dependencias, corre en Node, en el edge y en el navegador. El panel guarda el override, el storefront inyecta el CSS y la web se repinta sin rebuild. - En build (distribución): preset de Style Dictionary v5, que entiende DTCG de serie —incluido el color como objeto— y saca CSS, SCSS, JS y, si algún día hace falta, iOS y Android.
4. settings.schema.json — el customizer, declarado por el tema
Schema: schema/settings.schema.json, dentro del paquete del contrato.
El planteamiento es el de settings_schema.json de Shopify, con los tipos
alineados a los tokens.
{
"groups": [
{
"id": "design", "label": "Diseño", "icon": "palette",
"settings": [
{ "type": "color", "id": "primary", "label": "Color principal",
"token": "color.brand.primary" }, // ← el puente diseño ↔ formulario
{ "type": "select", "id": "mode", "label": "Modo", "default": "dark",
"options": [{ "value": "dark", "label": "Oscuro" }] }
]
},
{
"id": "commerce", "label": "Tienda",
"settings": [
{ "type": "number", "id": "codSurcharge", "label": "Recargo contrarreembolso", "unit": "€",
"visibleIf": { "setting": "commerce.paymentMethods", "equals": "cod" } }
]
}
],
"blocks": [
{ "type": "hero", "label": "Portada", "limit": 1,
"settings": [{ "type": "text", "id": "heading", "label": "Titular", "required": true }] }
]
}Tipos de campo: text, textarea, richtext, number, range, checkbox,
select, radio, color, font, image, url, email, tel, list,
token, más header y paragraph para maquetar el formulario.
Un campo con token es el único puente entre el formulario y el diseño:
cuando el cliente lo cambia, el valor va al token, de ahí a la variable CSS y de
ahí a todo lo que deriva de él. Por eso el panel no necesita saber nada del stack
del tema para recolorearlo.
5. Páginas y bloques
Rutas estándar (capabilities.pages), para que el catálogo sea predecible:
home · catalog · category · product · search · cart · checkout ·
order-confirmation · account · legal · about · contact · faq ·
blog · blog-post · not-found.
Los bloques son las secciones componibles. El tema declara cuáles sabe renderizar; el override dice en qué orden van y con qué ajustes:
{ "sections": { "home": [
{ "type": "hero", "settings": { "heading": "Cultivo de interior, bien hecho" } },
{ "type": "bestsellers", "settings": { "limit": 8 } }
]}}Cuando el editor repinta una sección
Al cambiar un ajuste, el editor sustituye el HTML de esa sección y deja el resto de la página en paz. Quien está editando no pierde el desplazamiento ni el carrito, y no se vuelve a pedir el catálogo entero por tocar una palabra.
Eso tiene dos consecuencias que no dan ningún error, y que hay que conocer.
1. El JavaScript no se vuelve a ejecutar, y lo anterior no se deshace solo.
Los ciclos de vida del framework (onMount, useEffect, onMounted) no se
disparan: el HTML entra ya hecho. Hay dos eventos para eso, que salen sobre
el nodo de la sección y burbujean:
| evento | cuándo | para qué |
|---|---|---|
pcc:seccion:descargada | antes de quitar el nodo viejo | parar bucles, quitar observadores, liberar WebGL |
pcc:seccion:cargada | después de poner el nuevo | volver a montar carruseles y animaciones |
Los dos llevan detail: { id, el }. 🔴 Sin escuchar el de descarga, cada
edición apila otro juego de escuchadores y otro bucle de animación sobre el
anterior; a las diez ediciones la página se arrastra.
2. Hay secciones que no se pueden pintar solas. Un héroe que fija el scroll de toda la página, un lienzo WebGL compartido, un scroll suave o una línea de tiempo que abarca varias secciones no existen fuera de su página: sustituir su nodo no falla, deja algo pintado y roto. Se declara en el catálogo y el editor recarga la página para esa sección en vez de sustituirla:
{ "type": "hero-cinematografico", "name": "Héroe cinematográfico", "aislable": false }Por defecto es true. Cuesta medio segundo más y enseña la verdad.
6. theme.override.json — la capa de la tienda
Schema: schema/override.schema.json, dentro del paquete del contrato.
{
"contract": "2.0",
"theme": "mascotas",
"themeVersion": "2.0.0",
"settings": { "brand": { "name": "Green Room" }, "design": { "primary": "#16a34a" } },
"tokens": { "color.surface.base": "#0d0f0e" },
"sections": { "home": [ /* … */ ] }
}Solo guarda lo que difiere. El orden de mezcla es
defaults del schema ← settings.json del tema ← override de la tienda, y la
mezcla es por campo, no por grupo: un override parcial no borra el resto.
7. Contrato de datos (commerce)
theme.json declara commerce.contract, no un SDK. Un tema no importa el SDK
del backend: consume el contrato de datos y el adaptador resuelve. Así el mismo
tema sirve para este backend hoy y para otro mañana, y —al revés— el backend
no queda casado con temas React.
Qué puede pedir un tema, método a método: Contrato de comercio.
8. Herramientas
pcc-theme init mi-tema --name "Mi Tema" --stack astro # tema nuevo que ya vende y ya valida
pcc-theme validate themes/mascotas # forma (JSON Schema) + coherencia entre ficheros
pcc-theme audit themes/mascotas # qué se ejecutaría, con qué dominios habla, bloqueantes
pcc-theme pack themes/mascotas # valida, audita, busca secretos, construye y hace el .zip
pcc-theme sign themes/mascotas --key privada.pem --publisher "Tu estudio"
pcc-theme verify themes/mascotas --pubkey publica.pem
pcc-theme css themes/mascotas # variables CSS ya resueltas
pcc-theme info themes/mascotas # resumen del temainit crea el tema entero (ficheros del contrato, plantillas, textos, demo y
código). Necesita una carpeta o --id; --stack admite next, astro,
sveltekit, nuxt, react-router y vite-react (por defecto next).
validate hace dos pasadas. La de forma valida cada fichero contra su JSON
Schema. La de coherencia es la que caza lo que ningún schema ve: un campo de
color apuntando a un token que no existe, un bloque que el customizer ofrece y el
tema no implementa, un override de otra versión, un color cuyo hex y cuyos
components no son el mismo color.
audit revisa el tema como lo haría el panel antes de instalarlo: enseña los
comandos de instalar, construir y arrancar que se ejecutarían de verdad, lista
los dominios de fuera con los que habla el código (avisando de los que no estén
declarados en runtime.hosts) y separa los bloqueantes (sin fichero de bloqueo,
scripts de instalación peligrosos, hallazgos graves en el código) de los avisos.
Sale con error si hay algún bloqueante.
pack lo recorre todo en orden —contrato, auditoría, qué ficheros entran,
secretos, dependencias portátiles (las rutas file: a @pcreative/* se
empaquetan en vendor/), instalación y construcción reales— y escribe
<id>-<version>.zip (--salida para cambiar el nombre). Con --key y
--publisher además firma el paquete; --sin-construir se salta la
construcción.
sign firma el tema con tu clave privada y deja la firma en theme.sig. Pide
--key y --publisher a la vez; --kid nombra la clave (por defecto, 1).
verify comprueba esa firma con una clave pública, de un fichero (--pubkey)
o de una dirección (--url), y si no casa dice qué ficheros se han alterado,
cuáles sobran y cuáles faltan. Así sabe quien instala que el tema es el que
firmaste y que nadie lo ha tocado después.
Todos los comandos y sus opciones: Los comandos.
9. Migrar un tema de la 1.0
theme.meta.json+ la parte de identidad detheme.config.ts→theme.json.tokensdetheme.config.ts→tokens.json. Para no tocar componentes, cada token llevadev.pcreative.csscon el nombre de variable que ya usaban.- Las escalas que
theme-tokens.tscalculaba en TypeScript → tokens condev.pcreative.derive. Misma función, pero declarada. - El resto de
theme.config.ts→settings.schema.json(los campos) ysettings.json(los valores). theme.override.ts→theme.override.json.lib/commerce.ts→ adaptador del contrato de datos.pcc-theme validatehasta que salga limpio.
mascotas está migrado y sirve de plantilla.