diff --git a/.changeset/consent-hydration-warning.md b/.changeset/consent-hydration-warning.md new file mode 100644 index 0000000..a4750f0 --- /dev/null +++ b/.changeset/consent-hydration-warning.md @@ -0,0 +1,14 @@ +--- +'@ogs-tech/press-web': patch +--- + +fix: suppress the React hydration warning caused by the consent bootstrap script + +The pre-paint consent script stamps `` +before React hydrates, but the server-rendered element never carries that +attribute (reading the cookie in the RSC tree would force the route dynamic). +React 19 reported a hydration mismatch for every returning visitor who had +already decided. The host template's `` now sets +`suppressHydrationWarning` — the canonical fix for pre-paint attribute +stamping (the next-themes pattern); it silences only that element's attribute +diff, so real content mismatches in children still surface. diff --git a/.changeset/layout-fine-tuning.md b/.changeset/layout-fine-tuning.md new file mode 100644 index 0000000..079a1e4 --- /dev/null +++ b/.changeset/layout-fine-tuning.md @@ -0,0 +1,63 @@ +--- +'@ogs-tech/press-web': patch +'@ogs-tech/create-press': patch +--- + +fix: layout/grid fine-tuning — Column span, uniform prose column, full-width footer, scrollable drawer, grid-showcase seed + +`@ogs-tech/press-web`: + +- **`` span no longer collapses to one track.** The `grid-column: span N` + shorthand stores the span in the START longhand, so the subsequent + `grid-column-start: auto` (the undeclared-`start` case) erased it — every + Column rendered as span 1. The span now rides on `grid-column-end`. +- **One editorial prose column for every atom.** `--press-container-prose` + moves from `72ch` to `42rem` (≈72ch at the 16px body size): a `ch` value + resolves against each consuming element's font, which gave a 28px heading a + ~2× wider "prose" column than a paragraph. The column is now left-aligned to + the lg-container rail (same margin math as ``) + instead of viewport-centered, so atoms and organisms share a single left + axis at every viewport. +- **Hero gap is tier-scaled** (`{ base: 'md', lg: 'lg' }`). A 12-track grid + always carries 11 interior column-gaps, so a flat `lg` (48px) forced a 528px + minimum width and overflowed phone viewports even with every column stacked. +- **Footer goes full-width** (`maxWidth="full"`), matching the Navbar so both + chrome surfaces share edge-to-edge geometry — chrome is full-bleed, content + Containers are the constrained ones. +- **The mobile nav drawer panel scrolls** (`max-height` + `overflow-y: auto` + + `overscroll-behavior: contain`): body scroll is locked while the drawer is + open, so an overflowing menu tail was previously unreachable. +- **One button family.** Every button-shaped link the engine renders (button + atom, navbar CTA on desktop and in the drawer, hero CTA, cta-banner button) + now shares a single visual contract: an always-present transparent border + keeps both voices the same height, the secondary outline rides on + `currentColor` (it used the neutral `--press-color-border`, which read as + unfinished next to primary-colored text), and all surfaces gain hover / + active states derived from the variant's own colors via `color-mix` + (primary darkens toward ink, never black) plus a `:focus-visible` ring. + The cookie-consent banner buttons get the same secondary border, hover, + and focus treatment. No new tokens; flat strokes-over-shadows preserved. +- **Quote citations are anchored** with a typographic em-dash (`cite::before`) + — the cite sits outside the blockquote's left border and floated without it. + +`@ogs-tech/create-press`: + +- The seeded "Hello from press" home becomes a layout-system showcase — it + opens with the responsive 2-column `preset-organism.hero` (image + CTA) and + closes with a centered `preset-organism.cta` banner around the ~72ch prose + atom sequence. Demo content is deduplicated by design: one image, three + distinct CTA labels. +- The seed fills the bootstrap's bare navbar with demo navigation (Home, an + external GitHub item, a "Get started" CTA) so the header and the MobileNav + hamburger render out of the box. +- The example `Callout` custom block now wraps itself in ``, + demonstrating the non-atom custom-block contract (organisms own their own + width; only atoms are re-centered by the engine's prose selector). +- The seeded spacer between the GitHub button and the cta banner drops from + `lg` to `md` — the banner already carries its own section margin, and the + stacked pair read as a ~120px dead zone. +- Every seeded CTA now has its own destination: the cta banner points at the + press page on the OGS site (`https://useogs.com/press`, the launch-day + route), the navbar "Get started" opens the published + `@ogs-tech/create-press` npm page (the real scaffold quickstart), and the + hero "Read the docs" / "Star on GitHub" keep their repo semantics. diff --git a/.changeset/layout-grid-system.md b/.changeset/layout-grid-system.md new file mode 100644 index 0000000..9b8a7cb --- /dev/null +++ b/.changeset/layout-grid-system.md @@ -0,0 +1,45 @@ +--- +'@ogs-tech/press-web': major +--- + +feat!: engine-owned layout primitives (Container / Grid / Row / Column) + responsive nav + +`@ogs-tech/press-web` now ships four React layout primitives — `Container`, +`Grid`, `Row`, `Column` — under `packages/web/src/layout/` (public export from +the package entrypoint). Primitives are server-first, zero-runtime: every +responsive behavior is expressed through a three-tier `Responsive` prop +shape (`base=0`, `md=768px`, `lg=1024px`) that lowers to inline CSS custom +properties consumed by `theme.css` via a `var(a, var(b, var(c, default)))` +cascade. All four preset organisms — `Hero`, `Cta`, `Navbar`, `Footer` — are +refactored to consume the primitives: `Hero` gains a responsive 2-column layout +(text 7 / image 5 on md+, stacked at base), `Cta` moves its boxy visual onto an +inner `data-cta="frame"` wrapper, `Navbar` composes brand + links + CTA through +nested Rows, and `Footer` wraps its `` in a Container. + +A companion `MobileNav` client component adds a hamburger drawer for narrow +viewports (below `md`): toggle, aria-expanded, aria-modal="true" dialog, +Escape to close, body scroll lock, focus management. The desktop nav Row is +hidden below `md` and the hamburger takes over — no viewport-observer JS, just +a small stateful client component matched to a CSS media-query swap. + +The `preset-layout` CMS palette category stays declared and labelled but ships +**zero components** — reserved for future organism-nested config components +(pattern: `preset-molecule.nav-item`). + +BREAKING (visual): `main` no longer caps content at 72ch. Every preset atom +(`preset-atom.*`) and every custom atom (`custom-atom.*`) rendered inside `main` +preserves the ~72ch editorial reading width via a new CSS selector — the change +is transparent for adopters using engine atoms. Migration for adopter CUSTOM +NON-ATOM blocks that relied on the old cap: wrap in +`` (or `"lg"` for the wider content width) to +restore the desired width. + +BREAKING (interactive client component): the navbar now mounts a client-side +`MobileNav`. Adopters overriding `preset-organism.navbar` via +`components={{ 'preset-organism.navbar': MyNavbar }}` are unaffected (their +component is rendered instead). Adopters KEEPING the engine navbar receive the +hamburger + drawer automatically. + +Contract stability: `PressSchema`, `PageBody`, `HeaderBlocks`, `FooterBlocks` +are byte-identical. No CMS re-seed, no admin re-login, no data migration. No +`press-cms` bump — the CMS side is untouched. diff --git a/.gitignore b/.gitignore index d7e2afc..0e70269 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ build/ # Git worktrees are local-only workspace; never track them .claude/worktrees/ +.playwright-mcp/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 57f7064..f8e3806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,89 @@ Release (changesets): add a changeset under `.changeset/` for any engine change, ## Architecture — the moving parts +### Layout primitives (`packages/web/src/layout/`) + +Engine-owned responsive layout is code, not content. Four React primitives — +`Container`, `Grid`, `Row`, `Column` — live in `packages/web/src/layout/`, +exported from `@ogs-tech/press-web`. Every primitive emits semantic HTML + +`data-press-layout=""` + per-instance CSS custom properties; visual +rules read the vars via a `var(a, var(b, var(c, default)))` cascade in +`theme.css` so three-tier responsive behavior (`base 0` / `md 768px` / +`lg 1024px`) is expressed in CSS with zero runtime JS. The `Responsive` prop +shape (`T | { base: T; md?: T; lg?: T }`) is uniform across every responsive +primitive prop. Container is the one non-responsive primitive — it picks a +single tier from a fixed width scale (`prose | sm | md | lg | xl | full`). +Two CSS subtleties are load-bearing: (1) Column's span rides on +`grid-column-END` — the `grid-column: span N` shorthand stores the span in the +START longhand, so a later `grid-column-start: auto` (the undeclared-`start` +case) would erase it and collapse every column to one track. (2) A 12-track +grid always carries 11 interior column-gaps, so a Grid's minimum width is +`11 × gap` even when every column spans 12 — a flat `gap="lg"` (48px) means a +528px floor that overflows phones; organisms declare a tier-scaled gap +(`{ base: 'md', lg: 'lg' }`, the Hero pattern) instead. + +**Why two surfaces named `layout`.** (1) DEV-facing — the React primitives above, +consumed by engine organisms, future page-set-plugin templates, and adopter +custom blocks. (2) CMS-facing — the `preset-layout` Atomic Design category +stays declared in `PRESET_LAYERS` and labelled in the admin picker but ships +ZERO components today. The palette is reserved for future *nested-only* config +components (pattern: `preset-molecule.nav-item`) that a future organism admits +via a `component:` field. Layout is NEVER placed by the editor as a top-level +block — the Strapi 5 constraint "a component cannot contain a `dynamiczone`" +rules out polymorphic-child nesting inside a component. + +**Data-attr namespace is distinct from blocks.** Primitives use +`data-press-layout=""`, deliberately not `data-block="preset-*"`. +Primitives never have a `__component`, never appear in `PageBody`, never flow +through `BlockRenderer`. + +**Breakpoints are TS constants, not CSS vars.** `@media (min-width: var(--x))` +is unsupported in production browsers, so `BREAKPOINTS` in +`src/layout/breakpoints.ts` and the literal pixel values in `theme.css` media +queries are the two sources — `src/layout/breakpoints.test.ts` reads +`theme.css` and asserts both sides match, catching any drift. + +**Tokens live in `FIXED_TOKENS`, not adopter config.** Container widths, +`paddingX`, and the three grid gap sizes are engine-fixed (same policy as +`--press-space-*` / `--press-text-*`). Values are duplicated literals — not +`var()`-referenced against `--press-space-*` — because FIXED_TOKENS is the +source of truth and cross-referencing scales makes future edits fragile. Every +new var goes through `buildThemeStyle`'s single `:root` injection point. + +**Shell is full-width; atoms preserve prose width via a selector.** `main` has +no `max-width`; a single rule (`main [data-block^="preset-atom."], +main [data-block^="custom-atom."] { max-width: var(--press-container-prose); +… }`) restores ~72ch editorial reading width for every preset atom AND every +custom atom — without touching a single atom `.tsx`. The `prose` token is +**rem-anchored** (`42rem` ≈ 72ch at the 16px body size) on purpose: a `ch` +value resolves against each consuming element's font, which would give a 28px +heading a ~2× wider "prose" column than a paragraph — the editorial column +must be identical for every atom. The column is **left-aligned to the lg +container rail**, not viewport-centered: its margin-start mirrors the +`` centering math + gutter, so atoms share one left +axis with hero/cta/callout at every viewport (a centered narrow column next to +lg organisms produced a zig-zag of left edges). Organisms and non-atom customs are excluded +on purpose: they own their own `` (the scaffold's example `Callout` +demonstrates the pattern). Header and footer chrome shells keep only the +border stroke + vertical padding; horizontal composition is the refactored +organisms' job — and BOTH chrome organisms (Navbar, Footer) use +`maxWidth="full"`: chrome is edge-to-edge, content Containers are the +constrained ones. + +**Mobile nav is the one client-side responsive component.** `chrome/mobile-nav.tsx` +is a `'use client'` hamburger + drawer mounted inside `Navbar`, matched by CSS +media queries to the desktop nav Row (`[data-navbar-desktop]` visible ≥768px; +`[data-mobile-nav="toggle"]` visible <768px). Escape closes; a backdrop click +closes only when the click target IS the backdrop (`target === currentTarget` — +clicks inside the panel never close); body scroll locks while open — which is +why the drawer panel itself scrolls (`max-height` + `overflow-y: auto` + +`overscroll-behavior: contain`), or a long menu's tail would be unreachable; +aria-expanded/aria-modal wired; focus moves to the first link on open and +restores to the toggle on close. Deliberate exception to the +"server-first, zero-runtime layout" default — a viewport-observer approach +would drag the entire layout system into client-space; a fixed CSS breakpoint ++ small toggle state is the minimal viable contract. + ### Materialization (`.press/web`) The Next host is **not** scaffolded. `packages/web/templates/host/` is copied to @@ -96,8 +179,13 @@ PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a categ one layer, unified from the old `section.*`/`chrome.*` palettes. - `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` + `preset-template` are RESERVED (labelled, no components yet): - layout ← the Grid System task; template ← page-set plugins. + - `preset-layout` is RESERVED (labelled, no components yet). The Grid System + task shipped the DEV-facing layout primitives under + `packages/web/src/layout/` (see "Layout primitives" above); the CMS-facing + category stays labelled and empty, seat for a future *nested-only* config + component (pattern: `preset-molecule.nav-item`) that a future organism admits + via a `component:` field. + - `preset-template` is RESERVED (labelled, no components yet) — page-set plugins. - **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 @@ -123,7 +211,9 @@ PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a categ serializer follows these nested refs; the generator emits nested-only components without `__component` and adds `HeaderBlocks`/`FooterBlocks` unions. `bootstrap()` seeds `header: [preset-organism.navbar]`, `footer: [preset-organism.footer]` exactly once - (plugin-store flag) — an editor-emptied zone is respected. + (plugin-store flag) — an editor-emptied zone is respected. The bootstrap navbar is + BARE (no items/cta); the CLI's `seed.mjs` fills it with demo navigation (Home, + external GitHub, "Get started" CTA) in the same idempotent pass that fills identity. - **Picker presentation (admin bundle):** every engine component JSON sets `info.icon` (Strapi's fixed icon enum); the plugin's `./strapi-admin` bundle (`cms/admin/src/index.ts`) exists solely to `registerTrads` the category labels — the picker resolves accordion @@ -133,11 +223,12 @@ PRODUCT/plugin id (`plugin::press-cms.*`, `/api/press/schema`), never as a categ completeness; `preset-layout`/`preset-template` are labelled ahead of their components. Labels are presentation-only; uids never change for display. Adopter `src/admin/app.tsx` translations override the engine's. -- **Page templates:** `bootstrap()` also seeds a "Privacy Policy" page (slug - `privacy-policy`) exactly once (`privacyPageSeeded` flag, - `lib/seed-page-privacy-policy.ts`) — a DRAFT composed of `preset-atom.*` atoms with - placeholder guidance, never auto-published; an adopter page already on the slug - or an editor-deleted page is respected forever. +- **Page templates:** the once-shipped "Privacy Policy" bootstrap seed was RETIRED; + what remains is `lib/seed-page.ts` — a generic, idempotent `seedPage(strapi, opts)` + primitive (flag-first, slug-collision-respecting, DRAFT-only) that is deliberately + exported-but-unused, awaiting future page-seeding consumers (Plugin/Legal, + archetype templates). `bootstrap()` seeds NO page today; the only page an adopter + starts with is the CLI seed's published `home`. - On the web side, `BlockRenderer` merges the engine registries with the adopter map by `__component`: `{ ...atomBlocks, ...organismBlocks, ...components }` — engine `preset-atom.*` atoms (`src/atom-blocks.ts`), engine `preset-organism.*` organisms diff --git a/apps/playground/packages/cms/package.json b/apps/playground/packages/cms/package.json index 35452a6..933d309 100644 --- a/apps/playground/packages/cms/package.json +++ b/apps/playground/packages/cms/package.json @@ -31,6 +31,6 @@ "npm": ">=6.0.0" }, "strapi": { - "uuid": "b00875a1-05d6-42b5-b5c3-ef79987149da" + "uuid": "3df03067-2b3d-45b9-a1e5-f942710a84a4" } } diff --git a/apps/playground/packages/cms/scripts/seed.mjs b/apps/playground/packages/cms/scripts/seed.mjs index 603937f..e4d1694 100644 --- a/apps/playground/packages/cms/scripts/seed.mjs +++ b/apps/playground/packages/cms/scripts/seed.mjs @@ -1,9 +1,14 @@ // cms/scripts/seed.mjs — One-shot sample content for the cms host. Boots it // programmatically (CMS server must be STOPPED), fills the Site Settings single -// type (identity/SEO + theme), uploads a few tiny PNGs, and creates a PUBLISHED -// 'home' page that showcases the Atomic Design core palette (heading, paragraph, -// list, quote, separator, image, button, spacer) plus the adopter's custom-organism.callout, -// so the first `press dev` renders a branded, themed site. +// type (identity/SEO + theme + a demo navbar with links and CTA), uploads one +// tiny PNG (the hero cover — the media-crosses-REST proof), and creates a +// PUBLISHED 'home' page that opens with the engine's layout-system organisms +// (preset-organism.hero 2-col responsive grid, closing preset-organism.cta +// banner) around a ~72ch prose atom sequence (heading, paragraph, list, quote, +// separator, button, spacer) plus the adopter's custom-organism.callout, so the +// first `press dev` renders a branded, themed, fully-navigable site. Every CTA +// label on the page is distinct on purpose — demo content must not read as +// copy-paste repetition. // Skip-if-empty: seeds only a fresh CMS — existing content is never overwritten // (delete cms/.tmp to reset). import { createRequire } from 'node:module'; @@ -22,7 +27,8 @@ const SITE_SETTING_UID = 'plugin::press-cms.site-setting'; const SLUG = 'home'; // A visible 480×270 (16:9) placeholder cover — brand primary over accent — so -// preset-atom.image renders a real image rather than a degenerate 1×1 transparent pixel. +// the hero's media field renders a real image rather than a degenerate 1×1 +// transparent pixel (and proves an uploaded media crosses the REST contract). const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAeAAAAEOCAIAAADe+FMwAAAC+ElEQVR42u3UQQ0AIAwAsVnAB2LwhoLJws0s8NySJlVwj4t1DwANhQQABg2AQQMYNAAGDWDQABg0AAYNYNAAGDSAQQNg0AAYNIBBA2DQAAYNgEEDGDQABg2AQQMYNAAGDWDQABg0AAYNYNAAGDSAQQNg0AAGDYBBA2DQAAYNgEEDGDQABg2AQQMYNAAGDWDQABg0gEGrAGDQABg0gEEDYNAABg2AQQNg0AAGDYBBAxg0AAYNgEEDGDQABg1g0AAYNIBBA2DQABg0gEEDYNAABg2AQQNg0AAGDYBBAxg0AAYNYNAAGDQABg1g0AAYNIBBA2DQABg0gEEDYNAABg2AQQMYtAoABg2AQQMYNAAGDWDQABg0AAYNYNAAGDSAQQNg0AAYNIBBA2DQAAYNgEEDGDQABg2AQQMYNAAGDWDQABg0AAYNYNAAGDSAQQNg0AAGDYBBA2DQAAYNgEEDGDQABg2AQQMYNAAGDWDQABg0gEEDYNAAGDSAQQNg0AAGDYBBA2DQAAYNgEEDGDQABg2AQQMYNAAGDWDQABg0gEEDYNAAGDSAQQNg0AAGDYBBA2DQAAYNgEEDGDQABg1g0AAYNAAGDWDQABg0gEEDYNAAGDSAQQNg0AAGDYBBAxg0AAYNwO+gX24AGjJoAIMGwKABDBoAgwYwaAAMGgCDBjBoAAwawKABMGgADBrAoAEwaACDBsCgAQwaAIMGwKABDBoAgwYwaAAMGgCDBjBoAAwawKABMGgAgwbAoAEwaACDBsCgAQwaAIMGwKABDBoAgwYwaAAMGsCgJQAwaAAMGsCgATBoAIMGwKABMGgAgwbAoAEMGgCDBsCgAQwaAIMGMGgADBrAoAEwaAAMGsCgATBoAIMGwKABMGgAgwbAoAEMGgCDBjBoAAwaAIMGMGgADBrAoAEwaAAMGsCgATBoAIMGwKABDFoFAIMGwKABDBoAgwYwaAAMGgCDBjBoAAwawKABMGgADBrAoAEwaACDBsCgAQwaAIMGwKABJiqe+vFZK33AgwAAAABJRU5ErkJggg==', 'base64', @@ -32,6 +38,15 @@ const PNG = Buffer.from( // (packages/web/src/config/default-theme.ts) so the seeded look matches the // runtime fallback exactly — once written, this CMS record is the source of truth // and DEFAULT_THEME only acts as the CMS-down floor. Edit freely in the admin. +const REPO_URL = 'https://github.com/ogs-tech/press'; +// The cta banner's conversion link: the press page on the OGS site. Not live +// until the site launches — the path is the launch-day contract (adjust here +// if the final route differs). The other buttons keep their own semantics: +// starring and reading the docs live on the repo, "Get started" opens the +// published create-press package (the actual scaffold quickstart). +const PRESS_SITE_URL = 'https://useogs.com/press'; +const NPM_CREATE_URL = 'https://www.npmjs.com/package/@ogs-tech/create-press'; + const SITE_SETTINGS = { name: 'Press', url: 'http://localhost:3000', @@ -62,18 +77,39 @@ async function main() { try { // Site Settings (single type): the engine's bootstrap already seeded an EMPTY // record (Strapi runs plugin bootstrap during app.load()), so fill THAT record - // once with demo identity/SEO + theme. Runs before the page skip below so it is - // never gated by existing pages. Idempotent: skip once an editor set a name, so - // their values are never clobbered (reset by deleting cms/.tmp). + // once with demo identity/SEO + theme + a demo navbar. Runs before the page + // skip below so it is never gated by existing pages. Idempotent: skip once an + // editor set a name, so their values are never clobbered (reset by deleting + // cms/.tmp). + // + // The navbar the bootstrap seeded is BARE (no items, no cta) — without links + // the MobileNav hamburger doesn't even render, so the demo fills it. Items + // are url-based ('/' resolves through the same homeSlug anchor as the + // /home → / redirect); the page-RELATION path stays an admin-editing demo — + // the engine seeds no page besides 'home' to point one at. const settings = await app.documents(SITE_SETTING_UID).findFirst(); if (settings?.name) { console.log('[seed] site settings already filled — skipping.'); - } else if (settings) { - await app.documents(SITE_SETTING_UID).update({ documentId: settings.documentId, data: SITE_SETTINGS }); - console.log('[seed] site settings filled (empty record updated).'); } else { - await app.documents(SITE_SETTING_UID).create({ data: SITE_SETTINGS }); - console.log('[seed] site settings created.'); + const header = [ + { + __component: 'preset-organism.navbar', + items: [ + { label: 'Home', url: '/' }, + { label: 'GitHub', url: REPO_URL, newTab: true }, + ], + cta: { label: 'Get started', href: NPM_CREATE_URL, variant: 'primary' }, + }, + ]; + if (settings) { + await app + .documents(SITE_SETTING_UID) + .update({ documentId: settings.documentId, data: { ...SITE_SETTINGS, header } }); + console.log('[seed] site settings filled (empty record updated, navbar links seeded).'); + } else { + await app.documents(SITE_SETTING_UID).create({ data: { ...SITE_SETTINGS, header } }); + console.log('[seed] site settings created (navbar links seeded).'); + } } // Skip-if-empty: only seed a fresh CMS. Once any published page exists (the @@ -99,32 +135,46 @@ async function main() { }); return uploaded[0].id; }; - const coverImageId = await uploadImage('press-cover.png'); - console.log(`[seed] uploaded cover image id=${coverImageId}`); + const heroImageId = await uploadImage('press-hero.png'); + console.log(`[seed] uploaded hero image id=${heroImageId}`); - // Create the published "Hello from press" home from the Atomic Design core - // palette: atomic text blocks (heading/paragraph/list/quote), structural blocks - // (separator/button/spacer), media (image single), and the adopter's custom-organism.callout. + // Create the published "Hello from press" home as a layout-system showcase: + // it OPENS with preset-organism.hero (the engine's responsive 2-col grid — + // text 7 / image 5 on md+, stacked below; its media field is the single + // image on the page) and CLOSES with a centered preset-organism.cta banner, + // wrapping a ~72ch prose atom sequence — heading/paragraph/list/quote plus + // structural separator/button/spacer — and the adopter's + // custom-organism.callout. One image, one link target per block, three + // DISTINCT CTA labels: demo content stays deduplicated by design. const page = await app.documents(PAGE_UID).create({ data: { title: 'Hello from press', slug: SLUG, body: [ - { __component: 'preset-atom.heading', text: 'Hello from press', level: '1' }, + { + __component: 'preset-organism.hero', + eyebrow: 'Press engine', + title: 'Hello from press', + subtitle: 'A press-powered site, server-rendered end-to-end.', + image: heroImageId, + ctaLabel: 'Read the docs', + ctaHref: REPO_URL, + align: 'left', + }, + { __component: 'preset-atom.heading', text: 'What ships in the box', level: '2' }, { __component: 'preset-atom.paragraph', content: [ { type: 'paragraph', children: [ - { type: 'text', text: 'A press-powered site, ' }, - { type: 'text', text: 'server-rendered end-to-end', bold: true }, - { type: 'text', text: '. This prose lives in the CMS and renders as static HTML — no client hydration.' }, + { type: 'text', text: 'This prose lives in the CMS and renders as ' }, + { type: 'text', text: 'static HTML', bold: true }, + { type: 'text', text: ' — no client hydration.' }, ], }, ], }, - { __component: 'preset-atom.heading', text: 'What ships in the box', level: '2' }, { __component: 'preset-atom.list', content: [ @@ -146,6 +196,13 @@ async function main() { { type: 'text', text: ' — image, button, separator and spacer.' }, ], }, + { + type: 'list-item', + children: [ + { type: 'text', text: 'Layout organisms', bold: true }, + { type: 'text', text: ' — hero and call-to-action banners composed on the engine’s 12-column grid.' }, + ], + }, { type: 'list-item', children: [ @@ -165,27 +222,19 @@ async function main() { ], citation: 'The press engine', }, - { - __component: 'preset-atom.paragraph', - content: [ - { - type: 'paragraph', - children: [ - { type: 'text', text: 'Read the ' }, - { type: 'link', url: 'https://github.com/ogs-tech/press', children: [{ type: 'text', text: 'docs' }] }, - { type: 'text', text: ' to learn more.' }, - ], - }, - ], - }, { __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 + // margin — a lg spacer stacked on it read as a ~120px dead zone. + { __component: 'preset-atom.spacer', size: 'md' }, { - __component: 'preset-atom.image', - image: coverImageId, - caption: 'A single image — proof one media field crosses the REST contract.', + __component: 'preset-organism.cta', + title: 'Ready to press publish?', + subtitle: 'Scaffold a site, open the admin, and ship your first page in minutes.', + buttonLabel: 'Scaffold your site', + buttonHref: PRESS_SITE_URL, + align: 'center', }, - { __component: 'preset-atom.button', label: 'Get started', href: 'https://github.com/ogs-tech/press', variant: 'primary' }, - { __component: 'preset-atom.spacer', size: 'lg' }, { __component: 'custom-organism.callout', message: 'Adopter callout renders via the Project-zone block map', diff --git a/apps/playground/packages/web/blocks/custom/Callout.tsx b/apps/playground/packages/web/blocks/custom/Callout.tsx index 1d99959..ea994b8 100644 --- a/apps/playground/packages/web/blocks/custom/Callout.tsx +++ b/apps/playground/packages/web/blocks/custom/Callout.tsx @@ -1,3 +1,4 @@ +import { Container } from '@ogs-tech/press-web'; import type { CustomOrganismCallout } from 'playground-shared/types'; const VARIANT_STYLE: Record = { @@ -9,15 +10,20 @@ const VARIANT_STYLE: Record = { /** * Adopter-owned custom block (Project zone, Spec §4.2). The engine never names * this — it renders solely because press.blocks.ts maps 'custom-organism.callout' to it. + * + * A custom ORGANISM owns its own width: the engine's prose selector re-centers + * only atoms (`preset-atom.*` / `custom-atom.*`), so a non-atom custom block + * wraps itself in `` — the same contract the engine's own organisms + * follow (outer Container owns width + gutter, the visual lives on an inner + * element). */ export function Callout(props: CustomOrganismCallout) { const { message, variant } = props; return ( - + +
+ {message} +
+
); } diff --git a/docs/superpowers/plans/2026-07-16-layout-grid-system.md b/docs/superpowers/plans/2026-07-16-layout-grid-system.md new file mode 100644 index 0000000..eaeefae --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-layout-grid-system.md @@ -0,0 +1,2804 @@ +# Layout / Grid System (`preset-layout`, v1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship four engine-owned React layout primitives (`Container`, `Grid`, `Row`, `Column`) plus a mobile hamburger drawer, refactor all four preset organisms to consume them, rewrite the page shell so `main` is full-width, and preserve editorial 72ch prose width for atom blocks via a single CSS selector — while keeping the CMS wire contract byte-identical. + +**Architecture:** Layout is code-owned and lives under `packages/web/src/layout/`; it is never serialized, never in `PressSchema`, never admitted to a Dynamic Zone. Primitives emit semantic HTML + `data-press-layout=""` + inline CSS custom properties; visual rules live in `theme.css` and read the vars via a `var(a, var(b, var(c, default)))` cascade so three-tier responsive behavior (`base=0`, `md=768px`, `lg=1024px`) is expressed in CSS with zero runtime JS. The one deliberate exception is the mobile hamburger — a small `MobileNav` client component with toggle drawer, added on top of the CSS-only navbar to give nav-items a real mobile UX. The `preset-layout` CMS category stays declared in `PRESET_LAYERS` and labelled in the picker but ships **zero** components in this task (spec §9 non-goal). + +**Tech Stack:** React 18 + TypeScript, Next.js host template, `@ogs-tech/press-web` (TS source, no build), vitest + tsc as the quality gate, changesets for release, hand-rolled `act()`+`createRoot` harness for interactive client-component tests (per CLAUDE.md, deliberately NOT `@testing-library/react`). + +## Global Constraints + +Every task's requirements implicitly include this section. + +- **Runtime:** Node 20.x, pnpm 10.x. Run all commands from the repo root. +- **Quality gate:** there is **no eslint**. The gate is `tsc --noEmit` (typecheck) + vitest. A task is done only when both pass for the touched package(s). +- **Engine ships TS source:** `web` and `shared` have echo-only `build`; only `cms` compiles. Do not introduce bundling. +- **CMS is UNTOUCHED.** No component injection, no `schema.json` change, no serializer change, no seed. `preset-layout` category stays labelled in the picker and empty in content (spec §9). `packages/cms/**`, `packages/shared/**`, and `packages/cli/**` are OFF-LIMITS for this task. +- **Wire contract stable.** `PressSchema`, `PageBody`, `HeaderBlocks`, `FooterBlocks` are byte-identical after this plan. No generator changes, no `map-site-settings` changes for the primitives themselves. Zero re-seed, zero admin re-login, zero data migration. +- **Layout is not a block.** Primitives use `data-press-layout=""`, NEVER `data-block="preset-.*"`. Primitives have no `__component`, never appear in a DZ, never flow through `BlockRenderer`. +- **Three responsive tiers, fixed.** `BREAKPOINTS = { base: 0, md: 768, lg: 1024 }`. No `sm`/`xl`/`2xl` in v1. Breakpoints are TS constants; `@media (min-width: 768px|1024px)` in `theme.css` uses LITERAL pixel values (CSS does not accept `var()` inside media queries). A coordination test (Task 8) reads `theme.css` and asserts the literals match the TS constants. +- **Server-first, zero-runtime layout.** Every responsive behavior for the primitives is expressed in CSS via the `var()` cascade. The mobile hamburger (Task 13) is the deliberate, spec-approved-in-this-plan exception — a client component with toggle state, isolated to `chrome/mobile-nav.tsx`. +- **Zero adopter-overridable layout tokens.** Container widths, paddingX, grid gaps live in `FIXED_TOKENS` (same policy as `--press-space-*` / `--press-text-*`). +- **Visual-breaking change:** `main` no longer caps at 72ch. Preset atoms (`preset-atom.*`) and custom atoms (`custom-atom.*`) still read at ~72ch inside `main` via a single new selector. Adopter custom NON-ATOMS that assumed the old cap must wrap in `` — documented in the changeset. +- **`press-web` bumps MAJOR.** Two reasons: (1) visual-breaking shell change; (2) hamburger adds a new interactive client component to the public surface. `press-cms` stays untouched → no bump. +- **Interactive tests use hand-rolled harness.** Any test that renders a client component with state (Task 13) uses `// @vitest-environment jsdom` + `act()` + `createRoot` from `react-dom/client`, following `plugins/cookie-consent/cookie-consent-banner.test.tsx` exactly. Never `@testing-library/react` — the workspace's `node-linker=hoisted` layout materializes only Strapi-admin's react-19 RTL variant at root, which cannot render this package's react-18 elements. +- **Comment convention:** cite deliberate design decisions as `Spec §…` referencing `docs/superpowers/specs/2026-07-16-layout-grid-system-design.md`. +- **Language:** all code, comments, identifiers, and test descriptions in English. + +## File Structure + +**Create (`packages/web/src/layout/`):** +- `breakpoints.ts` — `BREAKPOINTS`, `Breakpoint`, `Responsive`, `normalizeResponsive`. +- `breakpoints.test.ts` — TS ↔ theme.css coordination test. +- `container.tsx` — `` + `ContainerProps`. +- `container.test.tsx` — Container SSR tests. +- `grid.tsx` — `` + `GridProps`. +- `grid.test.tsx` — Grid SSR tests. +- `row.tsx` — `` + `RowProps`. +- `row.test.tsx` — Row SSR tests. +- `column.tsx` — `` + `ColumnProps` + `Span` (1..12). +- `column.test.tsx` — Column SSR tests (inside a `` for shape realism). +- `index.ts` — barrel re-export of the four components + `Breakpoint` / `Responsive` / `BREAKPOINTS`. + +**Create (`packages/web/src/chrome/`):** +- `mobile-nav.tsx` — client component: hamburger button + drawer + toggle state + a11y (aria-expanded, aria-controls, Escape closes, body scroll lock). +- `mobile-nav.test.tsx` — interactive test with hand-rolled `act()`+`createRoot` harness under `// @vitest-environment jsdom`. + +**Modify (`packages/web`):** +- `src/index.ts` — re-export `Container`, `Grid`, `Row`, `Column`, `Breakpoint`, `Responsive`, `BREAKPOINTS`. Do NOT re-export `MobileNav` (internal to `Navbar`). +- `src/config/default-theme.ts` — extend `FIXED_TOKENS` with `container` (`widths` + `paddingX`) and `gridGap`. +- `src/config/build-theme-style.ts` — emit `--press-container-` (5 vars), `--press-container-padding-x` (1 var), `--press-grid-gap-` (3 vars). +- `src/config/build-theme-style.test.ts` — assert every new var is present with the correct value. +- `theme.css` — add primitive rules (§5 of the spec) and mobile-nav rules; then the shell/organism deltas from §7–§8 land in dedicated tasks. +- `src/sections/hero.tsx` + `src/sections/hero.test.ts` — refactor per §8.1 (responsive 2-col via ``). +- `src/sections/cta.tsx` + `src/sections/cta.test.ts` — refactor per §8.2 (inner `data-cta="frame"` wrapper). +- `src/chrome/navbar.tsx` + `src/chrome/navbar.test.ts` — refactor per §8.3 (Container + nested Rows, plus mount `MobileNav`). +- `src/chrome/nav-links.tsx` + `src/chrome/nav-links.test.ts` — internal `