From d960b1065998215dfc0f11293bdb4100deb296a7 Mon Sep 17 00:00:00 2001 From: Odenir Gomes Date: Mon, 20 Jul 2026 16:04:43 -0300 Subject: [PATCH 01/31] feat: add preset-organism.columns for editor-composed multi-column layouts - Introduced `preset-organism.columns`, allowing editors to create 2-4 column layouts with rich text, images, and buttons. - Added `preset-molecule.column` as a nested component within `preset-organism.columns`, supporting rich text, optional images, and buttons. - Implemented deep population in `buildBodyPopulate` to ensure nested components are correctly populated. - Updated serializers and generators to handle the new two-level nesting without requiring code changes. - Created a new `Columns` renderer that manages layout and rendering of columns based on specified ratios and gaps. - Enhanced theme styles to accommodate the new columns layout and button styles. - Added comprehensive tests for the new columns functionality, ensuring correct rendering and behavior. --- .changeset/columns-organism.md | 42 ++++++ CLAUDE.md | 17 ++- apps/playground/packages/cms/scripts/seed.mjs | 45 ++++++ .../packages/shared/types/generated.ts | 18 ++- packages/cli/templates/cms/scripts/seed.mjs | 45 ++++++ .../src/components/molecules/column.json | 21 +++ .../src/components/organisms/columns.json | 23 ++++ .../server/src/content-types/page/schema.json | 3 +- .../cms/server/src/lib/dz-populate.test.ts | 15 ++ packages/cms/server/src/lib/dz-populate.ts | 17 ++- .../server/src/lib/inject-components.test.ts | 44 +++++- .../cms/server/src/lib/inject-components.ts | 4 + .../server/src/lib/serialize-schema.test.ts | 80 +++++++++++ packages/web/src/generator/generate.test.ts | 67 +++++++++ packages/web/src/index.ts | 3 + packages/web/src/organism-blocks.ts | 2 + packages/web/src/sections/columns.test.ts | 129 ++++++++++++++++++ packages/web/src/sections/columns.tsx | 104 ++++++++++++++ packages/web/src/types/base.ts | 24 ++++ packages/web/theme.css | 64 +++++++-- 20 files changed, 749 insertions(+), 18 deletions(-) create mode 100644 .changeset/columns-organism.md create mode 100644 packages/cms/server/src/components/molecules/column.json create mode 100644 packages/cms/server/src/components/organisms/columns.json create mode 100644 packages/web/src/sections/columns.test.ts create mode 100644 packages/web/src/sections/columns.tsx diff --git a/.changeset/columns-organism.md b/.changeset/columns-organism.md new file mode 100644 index 0000000..cd0e6ff --- /dev/null +++ b/.changeset/columns-organism.md @@ -0,0 +1,42 @@ +--- +'@ogs-tech/press-cms': minor +'@ogs-tech/press-web': minor +--- + +feat: `preset-organism.columns` — the first editor-composed layout block + +`@ogs-tech/press-cms`: + +- **New page-body organism `preset-organism.columns`** (admitted statically in + `page/schema.json`, body only — the hero/cta placement rule). Layout controls + are flat CLOSED enums on the organism (the `hero.align` precedent): + `ratio` (`50-50 | 33-67 | 67-33 | 33-33-33 | 25-25-25-25`), `gap` + (`compact | normal | spacious`), `verticalAlign` (`top | center | bottom`). + The raw `GridGap`/`Span` scales are never exposed to the CMS. +- **New nested-only molecule `preset-molecule.column`** (rich text + optional + image + optional `preset-atom.button` — the `navbar.cta` nesting pattern); + repeatable 2–4 per block, never a DZ member. `preset-layout` stays reserved + and empty: the top-level block is a content ORGANISM that consumes the grid + internally, so "layout is never an editor-placed top-level block" holds. +- **`buildBodyPopulate` deep-populates the columns chain.** `populate: '*'` is + shallow; a column's `image`/`button` sit TWO levels below the DZ member and + silently came back empty — the exact shape that already forced the navbar's + deep populate in the chrome zones. +- Serializer and generator needed **zero code change** — the BFS follows the + two-level chain (`columns → column → button`) generically; new tests pin that. + +`@ogs-tech/press-web`: + +- **New `Columns` renderer** (`preset-organism.columns` in `organismBlocks`, + overridable like every organism) built on Container/Grid/Column, mirroring + the Hero: stacks at base, `25-25-25-25` goes 2×2 on md before 4-up on lg, + `spacious` gap is tier-scaled (never a flat `lg` — the 11-gap 528px floor). + Tolerant: no columns renders nothing; an empty column keeps its cell; a + column beyond the ratio's slots reuses the last span instead of dropping. +- Cells render rich text via `renderBlocks` + raw ``/`` — NOT the atom + components, whose `data-block="preset-atom.*"` would match the prose-width + rule at any depth and fight the cell's own width. +- theme.css: columns section styles + the per-column button joins the shared + button family (all variants and states). +- New exported types `PresetOrganismColumns` / `PresetMoleculeColumn`; the CLI + seed demonstrates a `33-33-33` block with a nested secondary button. diff --git a/CLAUDE.md b/CLAUDE.md index f8e3806..9389545 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,8 +175,19 @@ PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a categ for the layer set; each entry registers under `preset-${layer}`: - `preset-atom.*` — paragraph, heading, list, quote, image, button, separator, spacer. - `preset-molecule.nav-item` — nested inside the navbar; never a DZ member. - - `preset-organism.*` — hero, cta (page body) **and** navbar, footer (site chrome): - one layer, unified from the old `section.*`/`chrome.*` palettes. + - `preset-molecule.column` — nested inside `preset-organism.columns` (rich text + + optional image + optional nested `preset-atom.button`, the navbar-cta pattern); + never a DZ member. + - `preset-organism.*` — hero, cta, columns (page body) **and** navbar, footer + (site chrome): one layer, unified from the old `section.*`/`chrome.*` palettes. + `columns` is the editor-composed 2–4 column layout block: its controls are flat + CLOSED enums (`ratio`/`gap`/`verticalAlign`, the `hero.align` precedent — raw + `Span`/`GridGap` scales are never exposed to the CMS); the renderer maps them to + the layout primitives. Its per-column `image`/`button` sit TWO levels below the + DZ member, past what `populate: '*'` reaches — `buildBodyPopulate` + (`lib/dz-populate.ts`) deep-populates that chain explicitly (the navbar-populate + situation, body-side); without it they silently vanish from the wire while the + admin still shows them. - `preset-config.*` — seo, theme-colors, theme-radius, cookie-consent, cookie-category: non-block settings referenced by `component:` fields on Site Settings, never a DZ member. - `preset-layout` is RESERVED (labelled, no components yet). The Grid System @@ -189,7 +200,7 @@ PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a categ - **Preset PLACEMENT is declared statically, per content-type — NOT by the category.** The category carries the layer; where a preset block may go is listed in each `schema.json`: `preset-atom.*` in all three engine DZs (page `body`, site-setting - `header`/`footer`); `preset-organism.hero`/`.cta` in the page `body` only; + `header`/`footer`); `preset-organism.hero`/`.cta`/`.columns` in the page `body` only; `preset-organism.navbar`/`.footer` in `header`/`footer` only. This is why organisms span placements (a hero is body-only, a navbar is chrome-only) while sharing one category — placement lives in the schema, not the uid. Because the engine names its diff --git a/apps/playground/packages/cms/scripts/seed.mjs b/apps/playground/packages/cms/scripts/seed.mjs index e4d1694..454c812 100644 --- a/apps/playground/packages/cms/scripts/seed.mjs +++ b/apps/playground/packages/cms/scripts/seed.mjs @@ -222,6 +222,51 @@ async function main() { ], citation: 'The press engine', }, + // The editor-composed columns organism: thirds on desktop, stacked on + // phones — demonstrates the closed ratio/gap/verticalAlign enums and a + // per-column nested button (the navbar.cta pattern). + { + __component: 'preset-organism.columns', + ratio: '33-33-33', + gap: 'normal', + verticalAlign: 'top', + columns: [ + { + content: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'Server-rendered', bold: true }, + { type: 'text', text: ' — every column ships as HTML, zero client JS.' }, + ], + }, + ], + }, + { + content: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'Editor-composed', bold: true }, + { type: 'text', text: ' — 2–4 columns arranged in the admin, no code.' }, + ], + }, + ], + }, + { + content: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'Themed automatically', bold: true }, + { type: 'text', text: ' — pure token consumers, born branded.' }, + ], + }, + ], + button: { label: 'See the engine', href: REPO_URL, variant: 'secondary' }, + }, + ], + }, { __component: 'preset-atom.separator', variant: 'line' }, { __component: 'preset-atom.button', label: 'Star on GitHub', href: REPO_URL, variant: 'secondary' }, // md, not lg: the cta banner below already carries its own section diff --git a/apps/playground/packages/shared/types/generated.ts b/apps/playground/packages/shared/types/generated.ts index 085b1f0..0a69f2c 100644 --- a/apps/playground/packages/shared/types/generated.ts +++ b/apps/playground/packages/shared/types/generated.ts @@ -85,6 +85,15 @@ export interface PresetOrganismCta { align?: 'left' | 'center'; } +export interface PresetOrganismColumns { + __component: 'preset-organism.columns'; + id: number; + ratio?: '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25'; + gap?: 'compact' | 'normal' | 'spacious'; + verticalAlign?: 'top' | 'center' | 'bottom'; + columns?: PresetMoleculeColumn[]; +} + export interface CustomOrganismCallout { __component: 'custom-organism.callout'; id: number; @@ -105,6 +114,13 @@ export interface PresetOrganismFooter { text?: string; } +export interface PresetMoleculeColumn { + id: number; + content?: unknown; + image?: PressMedia; + button?: PresetAtomButton; +} + export interface PresetMoleculeNavItem { id: number; label: string; @@ -112,7 +128,7 @@ export interface PresetMoleculeNavItem { newTab?: boolean; } -export type PageBody = (PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | PresetOrganismHero | PresetOrganismCta | CustomOrganismCallout)[]; +export type PageBody = (PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | PresetOrganismHero | PresetOrganismCta | PresetOrganismColumns | CustomOrganismCallout)[]; export type HeaderBlocks = (PresetOrganismNavbar | PresetOrganismFooter | PresetAtomParagraph | PresetAtomHeading | PresetAtomList | PresetAtomQuote | PresetAtomImage | PresetAtomButton | PresetAtomSeparator | PresetAtomSpacer | CustomOrganismCallout)[]; diff --git a/packages/cli/templates/cms/scripts/seed.mjs b/packages/cli/templates/cms/scripts/seed.mjs index e4d1694..454c812 100644 --- a/packages/cli/templates/cms/scripts/seed.mjs +++ b/packages/cli/templates/cms/scripts/seed.mjs @@ -222,6 +222,51 @@ async function main() { ], citation: 'The press engine', }, + // The editor-composed columns organism: thirds on desktop, stacked on + // phones — demonstrates the closed ratio/gap/verticalAlign enums and a + // per-column nested button (the navbar.cta pattern). + { + __component: 'preset-organism.columns', + ratio: '33-33-33', + gap: 'normal', + verticalAlign: 'top', + columns: [ + { + content: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'Server-rendered', bold: true }, + { type: 'text', text: ' — every column ships as HTML, zero client JS.' }, + ], + }, + ], + }, + { + content: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'Editor-composed', bold: true }, + { type: 'text', text: ' — 2–4 columns arranged in the admin, no code.' }, + ], + }, + ], + }, + { + content: [ + { + type: 'paragraph', + children: [ + { type: 'text', text: 'Themed automatically', bold: true }, + { type: 'text', text: ' — pure token consumers, born branded.' }, + ], + }, + ], + button: { label: 'See the engine', href: REPO_URL, variant: 'secondary' }, + }, + ], + }, { __component: 'preset-atom.separator', variant: 'line' }, { __component: 'preset-atom.button', label: 'Star on GitHub', href: REPO_URL, variant: 'secondary' }, // md, not lg: the cta banner below already carries its own section diff --git a/packages/cms/server/src/components/molecules/column.json b/packages/cms/server/src/components/molecules/column.json new file mode 100644 index 0000000..3af34ff --- /dev/null +++ b/packages/cms/server/src/components/molecules/column.json @@ -0,0 +1,21 @@ +{ + "collectionName": "components_preset_molecule_columns", + "info": { + "displayName": "Column", + "icon": "stack", + "description": "One column of a Columns section: rich text, an optional image, and an optional button" + }, + "options": {}, + "attributes": { + "content": { "type": "blocks" }, + "image": { "type": "media", "multiple": false, "allowedTypes": ["images"] }, + "button": { "type": "component", "repeatable": false, "component": "preset-atom.button" } + }, + "config": { + "metadatas": { + "content": { "edit": { "label": "Content" } }, + "image": { "edit": { "label": "Image", "description": "Optional image rendered above the button." } }, + "button": { "edit": { "label": "Button", "description": "Optional call-to-action rendered at the end of the column." } } + } + } +} diff --git a/packages/cms/server/src/components/organisms/columns.json b/packages/cms/server/src/components/organisms/columns.json new file mode 100644 index 0000000..553a1a2 --- /dev/null +++ b/packages/cms/server/src/components/organisms/columns.json @@ -0,0 +1,23 @@ +{ + "collectionName": "components_preset_organism_columns", + "info": { + "displayName": "Columns", + "icon": "grid", + "description": "An editor-composed multi-column section shipped by the press engine: 2-4 columns of rich text, image, and button" + }, + "options": {}, + "attributes": { + "ratio": { "type": "enumeration", "enum": ["50-50", "33-67", "67-33", "33-33-33", "25-25-25-25"], "default": "50-50" }, + "gap": { "type": "enumeration", "enum": ["compact", "normal", "spacious"], "default": "normal" }, + "verticalAlign": { "type": "enumeration", "enum": ["top", "center", "bottom"], "default": "top" }, + "columns": { "type": "component", "repeatable": true, "component": "preset-molecule.column", "min": 2, "max": 4 } + }, + "config": { + "metadatas": { + "ratio": { "edit": { "label": "Column layout", "description": "How the columns split the width on desktop; every layout stacks on phones." } }, + "gap": { "edit": { "label": "Gap", "description": "Spacing between columns." } }, + "verticalAlign": { "edit": { "label": "Vertical alignment", "description": "How columns of different heights line up." } }, + "columns": { "edit": { "label": "Columns" } } + } + } +} diff --git a/packages/cms/server/src/content-types/page/schema.json b/packages/cms/server/src/content-types/page/schema.json index 7b5dfce..8c40e07 100644 --- a/packages/cms/server/src/content-types/page/schema.json +++ b/packages/cms/server/src/content-types/page/schema.json @@ -26,7 +26,8 @@ "preset-atom.separator", "preset-atom.spacer", "preset-organism.hero", - "preset-organism.cta" + "preset-organism.cta", + "preset-organism.columns" ] } } diff --git a/packages/cms/server/src/lib/dz-populate.test.ts b/packages/cms/server/src/lib/dz-populate.test.ts index 02f7710..0fd13f6 100644 --- a/packages/cms/server/src/lib/dz-populate.test.ts +++ b/packages/cms/server/src/lib/dz-populate.test.ts @@ -13,6 +13,21 @@ describe('buildBodyPopulate', () => { }); }); + it('deep-populates preset-organism.columns, whose column image/button sit two levels down', () => { + // `populate: '*'` is SHALLOW: it reaches the repeatable `columns` component + // itself but NOT the media/component refs inside each column — the exact + // navbar situation in buildChromeDzPopulate. Without this, a column's + // image/button is silently absent from the wire while the admin shows it. + expect(buildBodyPopulate(['preset-organism.columns', 'preset-organism.hero'])).toEqual({ + body: { + on: { + 'preset-organism.columns': { populate: { columns: { populate: { image: true, button: true } } } }, + 'preset-organism.hero': { populate: '*' }, + }, + }, + }); + }); + it('produces an empty `on` map when the dynamic zone has no components', () => { expect(buildBodyPopulate([])).toEqual({ body: { on: {} } }); }); diff --git a/packages/cms/server/src/lib/dz-populate.ts b/packages/cms/server/src/lib/dz-populate.ts index 639d783..9fbdb4e 100644 --- a/packages/cms/server/src/lib/dz-populate.ts +++ b/packages/cms/server/src/lib/dz-populate.ts @@ -9,10 +9,23 @@ * The component list is passed in (read by the caller from the page content-type * at request time) so the engine stays generic: it never hardcodes `custom-*` * block names — only what the registry currently admits. + * + * EXCEPT `preset-organism.columns`: its per-column `image` media and nested + * `button` component sit TWO levels below the DZ member (organism → repeatable + * `columns` → media/component), one past what `'*'` reaches — the same shape + * that forces the navbar's deep populate in buildChromeDzPopulate. Without + * this, a column's image/button silently comes back empty on the wire (the + * admin still shows them). `content` is a `blocks` JSON scalar — no populate key. */ -export const buildBodyPopulate = (components: string[]): { body: { on: Record } } => ({ +export const buildBodyPopulate = (components: string[]): { body: { on: Record } } => ({ body: { - on: Object.fromEntries(components.map((uid) => [uid, { populate: '*' as const }])), + on: Object.fromEntries( + components.map((uid) => + uid === 'preset-organism.columns' + ? [uid, { populate: { columns: { populate: { image: true, button: true } } } }] + : [uid, { populate: '*' as const }], + ), + ), }, }); diff --git a/packages/cms/server/src/lib/inject-components.test.ts b/packages/cms/server/src/lib/inject-components.test.ts index c905554..03c6452 100644 --- a/packages/cms/server/src/lib/inject-components.test.ts +++ b/packages/cms/server/src/lib/inject-components.test.ts @@ -183,8 +183,9 @@ describe('injectComponents', () => { const expected = [ 'preset-atom.paragraph', 'preset-atom.heading', 'preset-atom.list', 'preset-atom.quote', 'preset-atom.image', 'preset-atom.button', 'preset-atom.separator', 'preset-atom.spacer', - 'preset-molecule.nav-item', - 'preset-organism.hero', 'preset-organism.cta', 'preset-organism.navbar', 'preset-organism.footer', + 'preset-molecule.nav-item', 'preset-molecule.column', + 'preset-organism.hero', 'preset-organism.cta', 'preset-organism.columns', + 'preset-organism.navbar', 'preset-organism.footer', 'preset-config.seo', 'preset-config.theme-colors', 'preset-config.theme-radius', 'preset-config.cookie-category', 'preset-config.cookie-consent', ]; @@ -248,6 +249,43 @@ describe('injectComponents', () => { expect(components.get('preset-organism.cta')?.globalId).toBe('ComponentPresetOrganismCta'); }); + it('injects preset-organism.columns with its nested preset-molecule.column (never a DZ member)', () => { + // Same nesting contract as navbar/nav-item: the organism is a DZ block, the + // molecule exists only inside it. `columns` nests the repeatable molecule; + // the molecule reuses preset-atom.button (the navbar.cta pattern). + const components = new Map(); + const page = pageWithBody(['preset-atom.paragraph']); + const contentTypes = new Map([ + [PAGE_UID, page], + [SITE_SETTING_UID, siteSettingWithChrome()], + ]); + const strapi = { + get: (key: string) => + key === 'components' ? components : key === 'content-types' ? contentTypes : undefined, + log: { warn() {}, info() {}, debug() {}, error() {} }, + } as any; + + injectComponents({ strapi }); + admitCustomBlocks({ strapi }); + + expect(components.get('preset-organism.columns')?.category).toBe('preset-organism'); + expect(components.get('preset-organism.columns')?.globalId).toBe('ComponentPresetOrganismColumns'); + expect(components.get('preset-organism.columns')?.attributes).toMatchObject({ + ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' }, + gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' }, + verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' }, + columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' }, + }); + expect(components.get('preset-molecule.column')?.category).toBe('preset-molecule'); + expect(components.get('preset-molecule.column')?.attributes).toMatchObject({ + content: { type: 'blocks' }, + image: { type: 'media', multiple: false, allowedTypes: ['images'] }, + button: { type: 'component', repeatable: false, component: 'preset-atom.button' }, + }); + // Nested-only: the molecule never leaks into any Dynamic Zone. + expect(page.attributes.body.components).not.toContain('preset-molecule.column'); + }); + it('injects the organism chrome (navbar/footer) under category "preset-organism" with a derived globalId', () => { // navbar/footer are organisms too (unified from the old chrome.* palette); the // placement split lives in schema.json, not the category. @@ -277,6 +315,7 @@ describe('page body dynamic zone (static organism admission)', () => { const components = pageSchema.attributes.body.components as string[]; expect(components).toContain('preset-organism.hero'); expect(components).toContain('preset-organism.cta'); + expect(components).toContain('preset-organism.columns'); // The atoms remain admitted, unchanged. expect(components).toContain('preset-atom.paragraph'); expect(components).toContain('preset-atom.image'); @@ -341,6 +380,7 @@ describe('site-setting chrome dynamic zones (static admission)', () => { expect(components).toContain('preset-atom.button'); expect(components).not.toContain('preset-organism.hero'); expect(components).not.toContain('preset-organism.cta'); + expect(components).not.toContain('preset-organism.columns'); } }); diff --git a/packages/cms/server/src/lib/inject-components.ts b/packages/cms/server/src/lib/inject-components.ts index 4271151..cc704c6 100644 --- a/packages/cms/server/src/lib/inject-components.ts +++ b/packages/cms/server/src/lib/inject-components.ts @@ -8,8 +8,10 @@ import buttonSchema from '../components/atoms/button.json'; import separatorSchema from '../components/atoms/separator.json'; import spacerSchema from '../components/atoms/spacer.json'; import navItemSchema from '../components/molecules/nav-item.json'; +import columnSchema from '../components/molecules/column.json'; import heroSchema from '../components/organisms/hero.json'; import ctaSchema from '../components/organisms/cta.json'; +import columnsSchema from '../components/organisms/columns.json'; import navbarSchema from '../components/organisms/navbar.json'; import footerSchema from '../components/organisms/footer.json'; import seoSchema from '../components/config/seo.json'; @@ -75,6 +77,7 @@ const ENGINE_COMPONENTS: Array<{ layer: PresetLayer; name: string; schema: Recor { layer: 'atom', name: 'spacer', schema: spacerSchema as Record }, // Molecules — small composed units nested inside organisms (e.g. a navbar's links). { layer: 'molecule', name: 'nav-item', schema: navItemSchema as Record }, + { layer: 'molecule', name: 'column', schema: columnSchema as Record }, // Organisms — composed sections for the page body (hero/cta) and the site chrome // (navbar/footer). One unified layer (the old section.*/chrome.* palettes); the // placement split (body vs header/footer) is declared per content-type @@ -82,6 +85,7 @@ const ENGINE_COMPONENTS: Array<{ layer: PresetLayer; name: string; schema: Recor // so editors cannot break the chrome. { layer: 'organism', name: 'hero', schema: heroSchema as Record }, { layer: 'organism', name: 'cta', schema: ctaSchema as Record }, + { layer: 'organism', name: 'columns', schema: columnsSchema as Record }, { layer: 'organism', name: 'navbar', schema: navbarSchema as Record }, { layer: 'organism', name: 'footer', schema: footerSchema as Record }, // Config — non-block settings referenced by the Site Settings single type (seo / diff --git a/packages/cms/server/src/lib/serialize-schema.test.ts b/packages/cms/server/src/lib/serialize-schema.test.ts index 8e4b272..1effe97 100644 --- a/packages/cms/server/src/lib/serialize-schema.test.ts +++ b/packages/cms/server/src/lib/serialize-schema.test.ts @@ -255,3 +255,83 @@ describe('serializeSchema — chrome dynamic zones', () => { expect(() => serializeSchema(strapi)).toThrow(/plugin::press-cms\.site-setting.*not registered/); }); }); + +describe('serializeSchema — two-level nesting (preset-organism.columns)', () => { + // The navbar coverage above proves ONE level of nested refs; the columns chain + // is the first TWO-level chain (DZ member → repeatable molecule → nested atom). + // This pins the BFS as genuinely transitive, not accidentally depth-1. + const columnsStrapi = () => { + const components = new Map([ + ['preset-organism.columns', { + uid: 'preset-organism.columns', + attributes: { + ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' }, + gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' }, + verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' }, + columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' }, + }, + }], + ['preset-molecule.column', { + uid: 'preset-molecule.column', + attributes: { + content: { type: 'blocks' }, + image: { type: 'media', multiple: false, allowedTypes: ['images'] }, + button: { type: 'component', repeatable: false, component: 'preset-atom.button' }, + }, + }], + ['preset-atom.button', { + uid: 'preset-atom.button', + attributes: { + label: { type: 'string', required: true }, + href: { type: 'string', required: true }, + variant: { type: 'enumeration', enum: ['primary', 'secondary'], default: 'primary', required: true }, + }, + }], + ]); + const contentTypes: Record = { + 'plugin::press-cms.page': { + uid: 'plugin::press-cms.page', + info: {}, + attributes: { body: { type: 'dynamiczone', components: ['preset-organism.columns'] } }, + }, + 'plugin::press-cms.site-setting': { + uid: 'plugin::press-cms.site-setting', + info: {}, + attributes: { + header: { type: 'dynamiczone', components: [] }, + footer: { type: 'dynamiczone', components: [] }, + }, + }, + }; + return { + contentType: (uid: string) => contentTypes[uid], + get: (key: string) => (key === 'components' ? components : undefined), + } as any; + }; + + it('follows the chain columns → column → button: all three enter the map from one DZ admission', () => { + const out = serializeSchema(columnsStrapi()); + expect(Object.keys(out.components).sort()).toEqual([ + 'preset-atom.button', 'preset-molecule.column', 'preset-organism.columns', + ]); + // The layout enums survive verbatim (KEEP: type/enum/default). + expect(out.components['preset-organism.columns'].attributes.ratio).toEqual({ + type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50', + }); + // The nested refs keep component/repeatable so the generator can type them. + expect(out.components['preset-organism.columns'].attributes.columns).toEqual({ + type: 'component', repeatable: true, component: 'preset-molecule.column', + }); + expect(out.components['preset-molecule.column'].attributes.button).toEqual({ + type: 'component', repeatable: false, component: 'preset-atom.button', + }); + // min/max are authoring guards, never part of the wire contract (KEEP excludes them). + expect(out.components['preset-organism.columns'].attributes.columns).not.toHaveProperty('min'); + }); + + it('fail-fast reaches depth 2: a missing second-level ref (the button) throws', () => { + const strapi = columnsStrapi(); + (strapi.get('components') as Map).delete('preset-atom.button'); + expect(() => serializeSchema(strapi)).toThrow(/preset-atom\.button.*absent from the components registry/); + }); +}); diff --git a/packages/web/src/generator/generate.test.ts b/packages/web/src/generator/generate.test.ts index 59ed4e5..6dfa952 100644 --- a/packages/web/src/generator/generate.test.ts +++ b/packages/web/src/generator/generate.test.ts @@ -288,3 +288,70 @@ describe('generateTypes with chrome organisms (site-setting DZs)', () => { expect(legacy).toContain('export type PageBody'); }); }); + +describe('generateTypes with the two-level columns chain (preset-organism.columns)', () => { + // The navbar coverage above pins ONE level of nesting; the columns chain is the + // first TWO-level one (DZ member → repeatable molecule → nested atom). Pins that + // the generator needs zero code change for it (Spec §2 / §7). + const schema = { + contentTypes: { + 'plugin::press-cms.page': { + uid: 'plugin::press-cms.page', + info: { singularName: 'page' }, + attributes: { + title: { type: 'string', required: true }, + body: { type: 'dynamiczone', components: ['preset-organism.columns'] }, + }, + }, + }, + components: { + 'preset-organism.columns': { + uid: 'preset-organism.columns', + attributes: { + ratio: { type: 'enumeration', enum: ['50-50', '33-67', '67-33', '33-33-33', '25-25-25-25'], default: '50-50' }, + gap: { type: 'enumeration', enum: ['compact', 'normal', 'spacious'], default: 'normal' }, + verticalAlign: { type: 'enumeration', enum: ['top', 'center', 'bottom'], default: 'top' }, + columns: { type: 'component', repeatable: true, component: 'preset-molecule.column' }, + }, + }, + 'preset-molecule.column': { + uid: 'preset-molecule.column', + attributes: { + content: { type: 'blocks' }, + image: { type: 'media', multiple: false, allowedTypes: ['images'] }, + button: { type: 'component', repeatable: false, component: 'preset-atom.button' }, + }, + }, + 'preset-atom.button': { + uid: 'preset-atom.button', + attributes: { + label: { type: 'string', required: true }, + href: { type: 'string', required: true }, + variant: { type: 'enumeration', enum: ['primary', 'secondary'], default: 'primary', required: true }, + }, + }, + }, + }; + + const out = generateTypes(schema); + + it('emits the organism with the closed layout enums and the repeatable nested column array', () => { + expect(out).toContain("__component: 'preset-organism.columns'"); + expect(out).toContain("ratio?: '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25';"); + expect(out).toContain("gap?: 'compact' | 'normal' | 'spacious';"); + expect(out).toContain("verticalAlign?: 'top' | 'center' | 'bottom';"); + expect(out).toContain('columns?: PresetMoleculeColumn[];'); + }); + + it('emits the nested-only column without __component, typing media and the nested button ref', () => { + expect(out).toContain('export interface PresetMoleculeColumn {'); + expect(out).not.toContain("__component: 'preset-molecule.column'"); + expect(out).toContain('content?: unknown;'); // blocks → unknown fallback (documented) + expect(out).toContain('image?: PressMedia;'); // media survives at nesting depth 2 + expect(out).toContain('button?: PresetAtomButton;'); + }); + + it('keeps the DZ member alone in PageBody — nested components never enter the union', () => { + expect(out).toContain('export type PageBody = (PresetOrganismColumns)[];'); + }); +}); diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 3087e85..d6ce06a 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -14,6 +14,7 @@ export { Separator } from './blocks/separator'; export { Spacer } from './blocks/spacer'; export { Hero } from './sections/hero'; export { Cta } from './sections/cta'; +export { Columns } from './sections/columns'; export { Navbar } from './chrome/navbar'; export { Footer } from './chrome/footer'; export { organismBlocks } from './organism-blocks'; @@ -62,6 +63,8 @@ export type { PresetAtomSpacer, PresetOrganismHero, PresetOrganismCta, + PresetOrganismColumns, + PresetMoleculeColumn, } from './types/base'; export type { PressConfig, diff --git a/packages/web/src/organism-blocks.ts b/packages/web/src/organism-blocks.ts index f99f1a1..be3f427 100644 --- a/packages/web/src/organism-blocks.ts +++ b/packages/web/src/organism-blocks.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import { Hero } from './sections/hero'; import { Cta } from './sections/cta'; +import { Columns } from './sections/columns'; import { Navbar } from './chrome/navbar'; import { Footer } from './chrome/footer'; @@ -15,6 +16,7 @@ import { Footer } from './chrome/footer'; export const organismBlocks: Record> = { 'preset-organism.hero': Hero, 'preset-organism.cta': Cta, + 'preset-organism.columns': Columns, 'preset-organism.navbar': Navbar, 'preset-organism.footer': Footer, }; diff --git a/packages/web/src/sections/columns.test.ts b/packages/web/src/sections/columns.test.ts new file mode 100644 index 0000000..020317b --- /dev/null +++ b/packages/web/src/sections/columns.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { PresetMoleculeColumn } from '../types/base'; +import { Columns } from './columns'; + +// Mirrors the hero contract tests: renderers are called as functions and resolve +// media absolute against CMS_URL (unset here → engine default). +const render = (props: Record): string => + renderToStaticMarkup(Columns({ __component: 'preset-organism.columns', id: 1, ...(props as any) })); + +const text = (t: string): PresetMoleculeColumn['content'] => [ + { type: 'paragraph', children: [{ type: 'text', text: t }] }, +]; + +const col = (overrides: Partial = {}, id = 1): PresetMoleculeColumn => + ({ id, ...overrides }) as PresetMoleculeColumn; + +describe('Columns renderer', () => { + it('renders nothing when columns is missing or empty (tolerant draft)', () => { + expect(render({})).toBe(''); + expect(render({ columns: [] })).toBe(''); + }); + + it('wraps output in a
Container carrying data-block (the Hero shell pattern)', () => { + const html = render({ columns: [col({ content: text('a') }, 1), col({ content: text('b') }, 2)] }); + expect(html.startsWith(' { + const html = render({ columns: [col({}, 1), col({}, 2)] }); + expect(html.match(/--press-col-span:12/g)).toHaveLength(2); + expect(html.match(/--press-col-span-md:6/g)).toHaveLength(2); + }); + + it('maps 33-67 / 67-33 to asymmetric md spans', () => { + const narrowWide = render({ ratio: '33-67', columns: [col({}, 1), col({}, 2)] }); + expect(narrowWide).toContain('--press-col-span-md:4'); + expect(narrowWide).toContain('--press-col-span-md:8'); + const wideNarrow = render({ ratio: '67-33', columns: [col({}, 1), col({}, 2)] }); + expect(wideNarrow.indexOf('--press-col-span-md:8')).toBeLessThan(wideNarrow.indexOf('--press-col-span-md:4')); + }); + + it('maps 33-33-33 to three md:4 cells', () => { + const html = render({ ratio: '33-33-33', columns: [col({}, 1), col({}, 2), col({}, 3)] }); + expect(html.match(/--press-col-span-md:4/g)).toHaveLength(3); + }); + + it('25-25-25-25 goes 2×2 on md (span 6) before 4-up on lg (span 3)', () => { + const html = render({ + ratio: '25-25-25-25', + columns: [col({}, 1), col({}, 2), col({}, 3), col({}, 4)], + }); + expect(html.match(/--press-col-span-md:6/g)).toHaveLength(4); + expect(html.match(/--press-col-span-lg:3/g)).toHaveLength(4); + }); + + it('renders FEWER columns than the ratio has slots without phantom cells', () => { + const html = render({ ratio: '33-33-33', columns: [col({}, 1), col({}, 2)] }); + expect(html.match(/data-column="cell"/g)).toHaveLength(2); + }); + + it('a column beyond the ratio slots reuses the last span instead of being dropped', () => { + const html = render({ ratio: '50-50', columns: [col({}, 1), col({}, 2), col({}, 3)] }); + expect(html.match(/data-column="cell"/g)).toHaveLength(3); + expect(html.match(/--press-col-span-md:6/g)).toHaveLength(3); + }); + + it('defaults gap to "normal" (md at every tier) and maps compact/spacious', () => { + const two = [col({}, 1), col({}, 2)]; + expect(render({ columns: two })).toContain('--press-grid-gap-current:var(--press-grid-gap-md)'); + expect(render({ gap: 'compact', columns: two })).toContain('--press-grid-gap-current:var(--press-grid-gap-sm)'); + const spacious = render({ gap: 'spacious', columns: two }); + // Tier-scaled like the Hero: md at base (11-gap floor stays phone-safe), lg from lg up. + expect(spacious).toContain('--press-grid-gap-current:var(--press-grid-gap-md)'); + expect(spacious).toContain('--press-grid-gap-current-lg:var(--press-grid-gap-lg)'); + }); + + it('maps verticalAlign editorial vocabulary to Grid alignItems (default top → start)', () => { + const two = [col({}, 1), col({}, 2)]; + expect(render({ columns: two })).toContain('data-align-items="start"'); + expect(render({ verticalAlign: 'center', columns: two })).toContain('data-align-items="center"'); + expect(render({ verticalAlign: 'bottom', columns: two })).toContain('data-align-items="end"'); + }); + + it('renders rich text content via the shared blocks renderer', () => { + const html = render({ columns: [col({ content: text('Server-rendered') }, 1), col({}, 2)] }); + expect(html).toContain('data-column="content"'); + expect(html).toContain('

Server-rendered

'); + }); + + it('resolves the column image absolute against CMS_URL', () => { + const html = render({ columns: [col({ image: { url: '/uploads/c.png', alternativeText: 'Chart' } }, 1), col({}, 2)] }); + expect(html).toContain('src="http://localhost:1337/uploads/c.png"'); + expect(html).toContain('alt="Chart"'); + }); + + it('renders the button only when BOTH label and href are present, defaulting variant to primary', () => { + const withButton = render({ + columns: [col({ button: { label: 'Go', href: '/go' } as any }, 1), col({}, 2)], + }); + expect(withButton).toContain('data-column="button"'); + expect(withButton).toContain('data-variant="primary"'); + expect(withButton).toContain('href="/go"'); + expect(render({ columns: [col({ button: { label: 'Go' } as any }, 1), col({}, 2)] })).not.toContain('data-column="button"'); + expect(render({ columns: [col({ button: { href: '/go' } as any }, 1), col({}, 2)] })).not.toContain('data-column="button"'); + }); + + it('an EMPTY column still renders its cell — it holds the track, siblings must not reflow', () => { + const html = render({ columns: [col({ content: text('a') }, 1), col({}, 2)] }); + expect(html.match(/data-column="cell"/g)).toHaveLength(2); + expect(html.match(/data-column="content"/g)).toHaveLength(1); + }); + + it('never renders atom components inside cells (no nested data-block="preset-atom.*")', () => { + // The prose-width rule matches `main [data-block^="preset-atom."]` at any + // depth — an atom component inside a cell would fight the cell's own width. + const html = render({ + columns: [ + col({ content: text('a'), image: { url: '/uploads/c.png' }, button: { label: 'Go', href: '/go' } as any }, 1), + col({}, 2), + ], + }); + expect(html).not.toContain('data-block="preset-atom.'); + }); +}); diff --git a/packages/web/src/sections/columns.tsx b/packages/web/src/sections/columns.tsx new file mode 100644 index 0000000..788ceb5 --- /dev/null +++ b/packages/web/src/sections/columns.tsx @@ -0,0 +1,104 @@ +import type { Responsive } from '../layout/breakpoints'; +import type { Span } from '../layout/column'; +import type { GridAlignItems, GridGap } from '../layout/grid'; +import type { PresetMoleculeColumn, PresetOrganismColumns } from '../types/base'; +import { Container } from '../layout/container'; +import { Grid } from '../layout/grid'; +import { Column } from '../layout/column'; +import { renderBlocks } from '../blocks/blocks-content'; + +const CMS_URL = process.env.CMS_URL ?? 'http://localhost:1337'; + +type Ratio = NonNullable; +type Gap = NonNullable; +type VerticalAlign = NonNullable; + +/** + * CMS ratio → per-slot spans. The editor's vocabulary is a CLOSED enum key the + * renderer owns the meaning of — spans can evolve (e.g. better tablet behavior) + * without touching stored content. Every layout stacks at base; `25-25-25-25` + * goes 2×2 on md before 4-up on lg (4 × span-3 is too tight at 768px). + */ +const RATIO_SPANS: Record[]> = { + '50-50': [{ base: 12, md: 6 }, { base: 12, md: 6 }], + '33-67': [{ base: 12, md: 4 }, { base: 12, md: 8 }], + '67-33': [{ base: 12, md: 8 }, { base: 12, md: 4 }], + '33-33-33': [{ base: 12, md: 4 }, { base: 12, md: 4 }, { base: 12, md: 4 }], + '25-25-25-25': [ + { base: 12, md: 6, lg: 3 }, + { base: 12, md: 6, lg: 3 }, + { base: 12, md: 6, lg: 3 }, + { base: 12, md: 6, lg: 3 }, + ], +}; + +/** + * Semantic gap → GridGap tiers. The raw GridGap scale is never exposed to the + * CMS: a 12-track grid always carries 11 interior column-gaps, so a flat 'lg' + * (48px) means a 528px floor that overflows phones — 'spacious' therefore uses + * the Hero's tier-scaled pattern ('md' at base, 'lg' from lg up). + */ +const GAP_TIERS: Record> = { + compact: 'sm', + normal: 'md', + spacious: { base: 'md', lg: 'lg' }, +}; + +/** CMS editorial vocabulary (top/center/bottom) → Grid's technical axis values. */ +const ALIGN_ITEMS: Record = { + top: 'start', + center: 'center', + bottom: 'end', +}; + +function ColumnCell({ column, span }: { column: PresetMoleculeColumn; span: Responsive }) { + const hasImage = Boolean(column.image?.url); + const hasButton = Boolean(column.button?.label && column.button?.href); + return ( + + {/* renderBlocks + raw /
on purpose — NOT the Paragraph/Image/Button + atom components: those emit data-block="preset-atom.*", which the + prose-width rule (`main [data-block^="preset-atom."]` in theme.css) + matches at ANY depth and would fight the cell's own span/width. Same + reason the navbar inlines its cta anchor. */} + {column.content?.length ?
{renderBlocks(column.content)}
: null} + {hasImage ? ( + {column.image!.alternativeText + ) : null} + {hasButton ? ( +
+ {column.button!.label} + + ) : null} + + ); +} + +/** + * Engine organism `preset-organism.columns` — an editor-composed 2–4 column + * section built on Container/Grid/Column, mirroring the Hero pattern. The + * layout controls (`ratio`/`gap`/`verticalAlign`) are closed CMS enums mapped + * to primitive props above. + * + * Tolerant, mirroring hero/cta: no columns renders nothing; an EMPTY column + * still renders its cell (it holds the ratio's track — dropping it would + * reflow siblings); a column beyond the ratio's slots reuses the last slot's + * span and wraps to the next row rather than being discarded. + */ +export function Columns({ ratio, gap, verticalAlign, columns }: PresetOrganismColumns) { + if (!columns?.length) return null; + const spans = RATIO_SPANS[ratio ?? '50-50'] ?? RATIO_SPANS['50-50']; + return ( + + + {columns.map((col, i) => ( + + ))} + + + ); +} diff --git a/packages/web/src/types/base.ts b/packages/web/src/types/base.ts index 8544407..a9c2b52 100644 --- a/packages/web/src/types/base.ts +++ b/packages/web/src/types/base.ts @@ -132,6 +132,30 @@ export interface PresetOrganismCta { align?: 'left' | 'center'; } +/** + * Molecule `preset-molecule.column` — nested-only inside preset-organism.columns + * (mirrors cms molecules/column.json). Never a DZ member; the wire omits + * `__component` on nested component instances (the navbar `cta` situation), but + * the shared PresetAtomButton shape is kept for consistency with the generated + * types — the renderer reads only label/href/variant. + */ +export interface PresetMoleculeColumn { + id: number; + content?: BlocksContent; + image?: PressMedia; + button?: PresetAtomButton; +} + +/** Organism `preset-organism.columns` — engine-owned (mirrors cms organisms/columns.json). */ +export interface PresetOrganismColumns { + __component: 'preset-organism.columns'; + id: number; + ratio?: '50-50' | '33-67' | '67-33' | '33-33-33' | '25-25-25-25'; + gap?: 'compact' | 'normal' | 'spacious'; + verticalAlign?: 'top' | 'center' | 'bottom'; + columns?: PresetMoleculeColumn[]; +} + /** * Structural shape every dynamic-zone entry satisfies. The renderer only reads * `__component`/`id` and spreads the rest, so the generic envelope is enough for diff --git a/packages/web/theme.css b/packages/web/theme.css index 00d25f1..fcc4d49 100644 --- a/packages/web/theme.css +++ b/packages/web/theme.css @@ -213,7 +213,8 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); } /* Button family — ONE visual contract for every button-shaped link the engine renders: the button atom, the navbar CTA (desktop bar + mobile drawer), the - hero CTA, and the cta-banner button. The always-present transparent border + hero CTA, the cta-banner button, and the per-column button of + preset-organism.columns. The always-present transparent border keeps the filled and outlined voices the same height. The secondary border rides on currentColor — it self-themes with ANY adopter palette (a neutral --press-color-border stroke read as unfinished next to primary-colored @@ -224,7 +225,8 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); } [data-block="preset-atom.button"] a, [data-block="preset-organism.navbar"] [data-navbar="cta"], [data-block="preset-organism.hero"] [data-hero="cta"], -[data-block="preset-organism.cta"] [data-cta="button"] { +[data-block="preset-organism.cta"] [data-cta="button"], +[data-block="preset-organism.columns"] [data-column="button"] { display: inline-flex; align-items: center; justify-content: center; @@ -244,40 +246,47 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); } [data-block="preset-atom.button"] a[data-variant="primary"], [data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="primary"], [data-block="preset-organism.hero"] [data-hero="cta"], -[data-block="preset-organism.cta"] [data-cta="button"] { +[data-block="preset-organism.cta"] [data-cta="button"], +[data-block="preset-organism.columns"] [data-column="button"][data-variant="primary"] { background: var(--press-color-primary); color: var(--press-color-on-primary); } [data-block="preset-atom.button"] a[data-variant="primary"]:hover, [data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="primary"]:hover, [data-block="preset-organism.hero"] [data-hero="cta"]:hover, -[data-block="preset-organism.cta"] [data-cta="button"]:hover { +[data-block="preset-organism.cta"] [data-cta="button"]:hover, +[data-block="preset-organism.columns"] [data-column="button"][data-variant="primary"]:hover { background: color-mix(in srgb, var(--press-color-primary) 85%, var(--press-color-ink)); } [data-block="preset-atom.button"] a[data-variant="primary"]:active, [data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="primary"]:active, [data-block="preset-organism.hero"] [data-hero="cta"]:active, -[data-block="preset-organism.cta"] [data-cta="button"]:active { +[data-block="preset-organism.cta"] [data-cta="button"]:active, +[data-block="preset-organism.columns"] [data-column="button"][data-variant="primary"]:active { background: color-mix(in srgb, var(--press-color-primary) 72%, var(--press-color-ink)); } [data-block="preset-atom.button"] a[data-variant="secondary"], -[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"] { +[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"], +[data-block="preset-organism.columns"] [data-column="button"][data-variant="secondary"] { background: transparent; color: var(--press-color-primary); border-color: currentColor; } [data-block="preset-atom.button"] a[data-variant="secondary"]:hover, -[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:hover { +[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:hover, +[data-block="preset-organism.columns"] [data-column="button"][data-variant="secondary"]:hover { background: color-mix(in srgb, currentColor 10%, transparent); } [data-block="preset-atom.button"] a[data-variant="secondary"]:active, -[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:active { +[data-block="preset-organism.navbar"] [data-navbar="cta"][data-variant="secondary"]:active, +[data-block="preset-organism.columns"] [data-column="button"][data-variant="secondary"]:active { background: color-mix(in srgb, currentColor 18%, transparent); } [data-block="preset-atom.button"] a:focus-visible, [data-block="preset-organism.navbar"] [data-navbar="cta"]:focus-visible, [data-block="preset-organism.hero"] [data-hero="cta"]:focus-visible, -[data-block="preset-organism.cta"] [data-cta="button"]:focus-visible { +[data-block="preset-organism.cta"] [data-cta="button"]:focus-visible, +[data-block="preset-organism.columns"] [data-column="button"]:focus-visible { outline: 2px solid var(--press-color-primary); outline-offset: 2px; } @@ -358,6 +367,43 @@ h6[data-block="preset-atom.heading"] { font-size: var(--press-text-body); } } /* [data-cta="button"] is styled by the shared button family (always primary). */ +/* Engine section: preset-organism.columns — an editor-composed 2–4 column + layout on the 12-track grid (ratio/gap/verticalAlign are closed CMS enums + resolved by the renderer into the layout primitives' vars). Pure token + consumer (Spec §5.2). */ +[data-block="preset-organism.columns"] { + margin-block: var(--press-space-7); +} +/* Rhythm inside a cell: content / image / button stack with one gap, whatever + subset the editor filled in. */ +[data-block="preset-organism.columns"] [data-column="cell"] > * + * { + margin-top: var(--press-space-4); +} +[data-block="preset-organism.columns"] [data-column="content"] p { + margin: 0; + font-size: var(--press-text-body); + line-height: 1.7; +} +[data-block="preset-organism.columns"] [data-column="content"] p + p { + margin-top: var(--press-space-4); +} +[data-block="preset-organism.columns"] [data-column="content"] ul, +[data-block="preset-organism.columns"] [data-column="content"] ol { + margin: 0; + padding-left: var(--press-space-5); +} +[data-block="preset-organism.columns"] [data-column="content"] a { + color: var(--press-color-primary); + text-underline-offset: 2px; +} +[data-block="preset-organism.columns"] [data-column="image"] { + display: block; + width: 100%; + height: auto; + border-radius: var(--press-radius-md); +} +/* [data-column="button"] is styled by the shared button family (variant-aware). */ + /* Engine plugin: cookie-consent — the consent banner (cookie-consent Spec §5). Fixed bottom bar, 1px stroke over shadow, pure token consumer. The html attribute rule hides the banner BEFORE first paint for a visitor whose From fbcec585014da30dad580769fc28f2ec1fbdb6b4 Mon Sep 17 00:00:00 2001 From: Odenir Gomes Date: Mon, 20 Jul 2026 16:52:58 -0300 Subject: [PATCH 02/31] docs: composition-builder design spec (JSON tree builder replaces DZ mechanism) Co-Authored-By: Claude Fable 5 --- .../2026-07-20-composition-builder-design.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-composition-builder-design.md diff --git a/docs/superpowers/specs/2026-07-20-composition-builder-design.md b/docs/superpowers/specs/2026-07-20-composition-builder-design.md new file mode 100644 index 0000000..bc1556c --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-composition-builder-design.md @@ -0,0 +1,241 @@ +# Composition Builder — design spec + +Date: 2026-07-20 +Status: approved direction, pre-implementation +Breaking: YES — replaces the dynamic-zone composition mechanism engine-wide (pre-release; authorized) + +## 1. Problem + +Page composition today is a flat Strapi dynamic zone. Editors cannot express layout +structure beyond the closed `preset-organism.columns` block, because Strapi 5 forbids +dynamic zones inside components — polymorphic children are only legal on content-types. +The product vision requires: a per-page layout root (header/footer/children, so pages +can drop or replace chrome), rows with N arbitrary children, columns that nest rows +(full recursion), and adopter custom blocks usable anywhere in that tree. + +## 2. Decisions (brainstorm outcomes) + +| Decision | Choice | +| --- | --- | +| Scope | One spec, complete refactor of the composition mechanism (body + chrome) | +| Architecture family | Custom page-builder field storing a JSON tree (family C) | +| Recursion | Full — Row inside Column, unbounded depth | +| v1 admin UX | Structural: collapsible tree + per-node forms (no WYSIWYG canvas) | +| Block schema source | Registry-driven: Strapi components remain the schema catalog | +| Responsive control | Curated presets only (`ratio`/`gap`/`width`/`verticalAlign`); engine maps to base/md/lg | +| Site chrome | Site Settings header/footer DZs die; `pageDefaults` JSON holds the default chrome pages inherit | +| Compatibility | Breaking wire + palette change; no data migration (pre-release) | + +## 3. Data model — `PressTree` (`@ogs-tech/press-shared`) + +```ts +interface PressTree { version: 1; root: LayoutNode } + +interface LayoutNode { type: 'layout'; header: Slot; footer: Slot; children: Node[] } + +type Slot = + | { mode: 'inherit' } // resolve against Site Settings pageDefaults + | { mode: 'none' } // bare page + | { mode: 'custom'; children: Node[] }; // page-owned chrome + +type Node = RowNode | ColumnNode | BlockNode; + +interface RowNode { + id: string; // stable node id, generated by the builder UI + type: 'row'; + width: 'prose' | 'lg' | 'full'; // Container tier at top level; ignored when nested + ratio: Ratio; // closed enum, the current columns scale + gap: Gap; // 'compact' | 'normal' | 'spacious' + verticalAlign: VerticalAlign; // 'top' | 'center' | 'bottom' + children: ColumnNode[]; // 1..4, ratio-bound +} + +interface ColumnNode { id: string; type: 'column'; children: Node[] } // recursion point + +interface BlockNode { + id: string; + type: 'block'; + component: string; // palette uid, e.g. 'preset-atom.paragraph', 'custom-organism.callout' + data: Record; // validated against the component's registry schema +} +``` + +Rules baked into the model: + +- **Responsiveness never appears in the JSON.** `ratio`/`gap`/`width` are editorial + intents; the web renderer maps them to `Responsive` values (the `RATIO_SPANS` / + `GAP_TIERS` maps inherited from `sections/columns.tsx`). The 11-gap floor and the + 25-25-25-25 two-stage md/lg behavior stay engine-owned and untouchable by editors. +- **Media inside `data` is a reference, not a snapshot**: `{ assetId: number }`. The + cms plugin hydrates url/alt/dimensions server-side when serving pages and site + settings alike, so the wire + always carries fresh asset URLs and the JSON never rots. +- **`id` is for React keys and builder operations only** — never an `Entity`; block + instances stay out of the URN system (same rationale as `blockKey` today). +- `version` gates future migrations; readers reject unknown versions (fail-to-empty). + +### press-shared changes nature + +`press-shared` today is imported type-only. It now additionally ships the **runtime +tree validator** (pure TS, no deps) used by both sides: cms compiles it into its dist +(save-time validation), web consumes it as source (render-time guard). This is a +deliberate contract change: shared = wire types + pure wire validators, still zero +Strapi/Next imports. + +## 4. CMS side (`@ogs-tech/press-cms`) + +### Storage + +- `page.body`: dynamic zone → **custom field** (`plugin::press-cms.builder`, storage + type `json`) holding a `PressTree`. Draft & publish is untouched — the tree versions + with the page entry. +- `site-setting.pageDefaults`: new JSON field `{ header: Node[]; footer: Node[] }` — + the default chrome every page inherits. The `header`/`footer` DZs are removed. + Identity, SEO, theme values, cookie consent stay exactly as they are. + +### Components become a schema catalog + +- `preset-atom.*`, `preset-organism.*` (hero, cta, navbar, footer) and `custom-*` + stay registered via `injectComponents`, but no dynamic zone references them. They + are now pure schema descriptors: the builder generates per-block forms from them, + and the type-sync generator keeps emitting per-block `data` interfaces from them. +- `preset-layout` finally gains components: **`preset-layout.row`** and + **`preset-layout.column`** — schema descriptors for the layout nodes' own forms + (the `ratio`/`gap`/`width`/`verticalAlign` enums live there), so layout-node forms + are registry-driven exactly like block forms. +- **Removed:** `preset-organism.columns`, `preset-molecule.column` (superseded by the + mechanism), all DZ admission arrays in `page`/`site-setting` schema.json, and + `dz-populate.ts` entirely — JSON needs no populate, which kills the whole + "vanished from the wire but visible in the admin" bug class. +- `admitCustomBlocks` shrinks to custom-block *discovery*: it feeds the `custom-*` + catalog instead of pushing uids into DZ arrays (category labels remain the admin + bundle's `registerTrads` job, unchanged). The adopter + contract is unchanged: drop a component JSON under `src/components/custom-/` + and it appears in the builder's block palette on every slot. +- `serialize-schema` serves the full registered palette (it can no longer walk DZ + admission lists) plus the tree contract version, still at `GET /api/press/schema`. + +### Admin custom field (v1, structural) + +- Registered via `app.customFields.register` in the plugin's `strapi-admin` bundle. +- Collapsible tree editor: add / remove / reorder / indent-outdent nodes via buttons; + per-node form generated from the registry schema; media picked with Strapi's native + asset picker (stores `assetId`); node ids minted client-side (`crypto.randomUUID`). +- Structural invariants enforced by construction in the UI: Column only under Row, + Row children are Columns only, slot editors accept `Node[]`. +- Site Settings `pageDefaults` reuses the same field component scoped to a single + `Node[]` slot (no layout root). + +### Validation & seeds + +- Lifecycle `beforeCreate`/`beforeUpdate` on `page` and `site-setting` run the shared + validator; invalid trees are rejected with actionable messages (the UI should make + this unreachable; the lifecycle is the backstop for API writes). +- `bootstrap()` seeds `pageDefaults` once (plugin-store flag `pageDefaultsSeeded`) + with bare navbar/footer block nodes; an editor-emptied slot is respected forever. + The flag is not set when the Site Settings record is missing (self-healing order, + the `seedCookieConsent` precedent). +- The CLI `seed.mjs` writes the demo home as a `PressTree` (hero, prose blocks, a + 50-50 row demonstrating recursion, cta, custom callout) and fills demo navigation + into `pageDefaults` in the same idempotent pass that fills identity. + +## 5. Web side (`@ogs-tech/press-web`) + +### TreeRenderer replaces BlockRenderer + +- `LayoutNode` → emits `
` (resolved slot), `
` (children), `