diff --git a/.claude/docs/DESIGN.md b/.claude/docs/DESIGN.md index 681202f33..c1b0ee9c5 100644 --- a/.claude/docs/DESIGN.md +++ b/.claude/docs/DESIGN.md @@ -163,11 +163,32 @@ Example from Button — horizontal padding and inner gap: ### Layout containers (page sections) -Use primitive container width tokens with Tailwind max-width utilities: +A page does **not** pick its own `max-w-*`. It carries one of the four **container types** the layout +system ships (`semantic/layouts` in `@aziontech/theme`), and the type is chosen by what the page *is*: -```html -max-w-(--container-md) max-w-(--container-5xl) -``` +| Class | Type | Measure token | Today | Use for | +|---|---|---|---|---| +| `.layout-column` | Data | `--layout-measure` | 1620px | Lists, detail dashboards | +| `.layout-column-focused` | Focused | `--layout-measure-focused` | 1024px | Home, single-task multi-column pages | +| `.layout-column-form` | Form | `--layout-measure-form` | 1024px | Settings, in-page edit forms | +| `.layout-form-create` | Create | `--layout-measure-form-create` | 1192px | Dedicated create pages (also retunes `--layout-measure-control`) | + +Full-bleed is the **absence** of all four, never a `w-full`. The unit that picks a class is the **band**, +not the file: a tab showing a table is measured as data even when the tab beside it is a form. Within one +band the class is the same everywhere it must align — the scrolling body *and* its sticky action bar — or +the footer's buttons drift right of the form they submit. + +**The boundary is not part of the measure.** `.layout-boundary` (all three insets) and +`.layout-boundary-inline` (the sides only, for a sticky bar) widen the cap by exactly the inset when they +sit on the same block, so a padded page and a self-padded page resolve to the same content column. + +Rhythm is two steps and no more: `--layout-section-gap` between the sections of a parent, +`--layout-group-gap` between the parts inside one section. `.layout-section-start` / +`.layout-group-start` are the margin form, for a band whose parent cannot carry a `gap`. + +Every layout token is a `var()` reference to `--spacing-*` / `--container-*` — never a literal length — +which is what lets the layout scale inherit the spacing scale's breakpoints instead of restating them. +Full catalog: **Foundations → Layout** in Storybook. ### Fixed content widths @@ -512,6 +533,7 @@ The `validate-tokens.mjs` PreToolUse hook enforces these at write time. If a hoo - **Typography:** `font-family`, `font-proto-mono`, `font-sora`, `leading-*` (except `leading-none` on icons), `tracking-*`, `text-xs|sm|base|lg`, `text-(length:--text-*-font-size)` when a generated class exists. - **Spacing:** primitive `--spacing-1` … `--spacing-96`, legacy `spacing-elements-*`, arbitrary `p-4` / `gap-3` when a `spacings.data.js` token applies. - **Container:** Tailwind scale (`max-w-md`, `max-w-5xl`), arbitrary lengths (`max-w-[768px]`), legacy helpers (`.px-container`, `.py-container`, `.max-container-width`), and semantic layout tokens (`--container-px`, `--container-py`, `--container-max-width`). Use `max-w-(--container-)` only (`3xs` … `7xl` from `primitives/shape/container.js`). +- **Page layout:** a hand-rolled page cap (`max-w-*` on a page root) instead of one of the four container types, a `w-full` standing in for full-bleed, a third rhythm step beside `--layout-section-gap` / `--layout-group-gap`, and a literal length in `semantic/layouts.data.js` — every layout token is a `var()` reference to the spacing or container scale. - **Shape:** `rounded-md`, `rounded-lg`, any numeric radius. - **Shadow:** legacy `--card-shadow` (SCSS alias), bare Tailwind `shadow-md` without `var(--shadow-*)`, HEX/RGB in elevation. - **Animations:** see § Animations § Forbidden in animations. diff --git a/apps/storybook/src/stories/foundations/GetStarted.mdx b/apps/storybook/src/stories/foundations/GetStarted.mdx index 7c71e7931..ce762bfbd 100644 --- a/apps/storybook/src/stories/foundations/GetStarted.mdx +++ b/apps/storybook/src/stories/foundations/GetStarted.mdx @@ -103,6 +103,7 @@ as-is when pasted into your app. | Theme Colors | `src/tokens/theme/` + `semantic/colors.js` | | Typography | `src/tokens/semantic/texts.data.js` | | Spacing | `src/tokens/semantic/spacings.data.js` | +| Layout | `src/tokens/semantic/layouts.data.js` | | Responsive | `src/tokens/primitives/breakpoints.js` | ## 4. Stay on-pattern diff --git a/apps/storybook/src/stories/foundations/Layout.stories.js b/apps/storybook/src/stories/foundations/Layout.stories.js new file mode 100644 index 000000000..513b8863e --- /dev/null +++ b/apps/storybook/src/stories/foundations/Layout.stories.js @@ -0,0 +1,263 @@ +import { layoutsData } from '@aziontech/theme/layouts' + +import { CodeBlock, PageContainer, PageHeader } from '../../foundations/components/layout/index.js' + +// Rows straight from the theme token source — the page can never drift from the +// catalog. The value shown is the reference as authored (`var(--spacing-lg)`), because +// that indirection is the point: a layout token never restates a length, it names +// which spacing or container step it follows. +const tokenRows = Object.entries(layoutsData).map(([name, value]) => ({ + name: `--${name}`, + value +})) + +const group = (prefix) => tokenRows.filter((row) => row.name.startsWith(prefix)) + +const BOUNDARY_ROWS = group('--layout-boundary') +const RHYTHM_ROWS = tokenRows.filter((row) => row.name.endsWith('-gap')) +const MEASURE_ROWS = group('--layout-measure') + +// The four container types, in the order a product meets them: the widest first. +// `width` is what the cap resolves to today; it is shown so the three read as one +// scale rather than three unrelated numbers. +const CONTAINERS = [ + { + class: 'layout-column', + type: 'Data', + token: '--layout-measure', + width: '1620px', + use: 'Lists and detail dashboards.', + why: 'Capped so a table running to the viewport edge on an ultrawide screen does not put its row actions a head-turn away from the name that identifies the row — but no tighter, because more columns visible is the point.' + }, + { + class: 'layout-column-focused', + type: 'Focused', + token: '--layout-measure-focused', + width: '1024px', + use: 'Home, and any single-task page that still goes multi-column.', + why: 'One task, but the task is a rail plus a card grid — wide enough for those bands to breathe, and no wider.' + }, + { + class: 'layout-column-form', + type: 'Form', + token: '--layout-measure-form', + width: '1024px', + use: 'Settings and in-page edit forms.', + why: 'A single stacked column of fields. Past ~1200px the extra width lands entirely inside the controls: a label sits at the far left of the row from the input it names, and the eye travels the whole measure to pair them.' + }, + { + class: 'layout-form-create', + type: 'Create', + token: '--layout-measure-form-create', + width: '1192px', + use: 'A dedicated create page — sidebar hidden, sticky action bar.', + why: 'A sibling of the form measure rather than the same class, because it also retunes --layout-measure-control (256px → 472px) for every field row inside it. Home is focused too, but it has no field rows to retune.' + } +] + +const PAGE_SHAPE = ` +
+ + + +
+ +
+
+
+
` + +const BOUNDARY_SHAPE = ` +
+
+
+ + +
+ + +` + +export default { + title: 'Foundations/Layout', + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + controls: { disable: true }, + actions: { disable: true }, + docs: { + description: { + component: + 'The container system: how far content sits from the app chrome (BOUNDARY), how far things sit from each other (RHYTHM), and how wide a reading column may get (MEASURE). Four container types, two rhythm steps, one boundary — every value derived from the spacing and container scales rather than restating a length.' + }, + canvas: { sourceState: 'none' } + } + } +} + +export const Overview = { + name: 'Overview', + parameters: { + docs: { + description: { + story: + 'Pick a container type by what the page is, not by what it looks like. The unit that picks it is the BAND, not the file: a tabbed module whose tab bar is second-level navigation is several pages in one route, and a tab showing a table is measured as data even when the tab beside it is a form.' + } + } + }, + render: () => ({ + components: { PageContainer, PageHeader, CodeBlock }, + data() { + return { + CONTAINERS, + BOUNDARY_ROWS, + RHYTHM_ROWS, + MEASURE_ROWS, + PAGE_SHAPE, + BOUNDARY_SHAPE + } + }, + template: /* html */ ` + + + Three decisions, expressed once: how far content sits from the app chrome + (boundary), how far things sit from each other (rhythm), and how + wide a reading column may get (measure). Every value is a + var() reference to the spacing or container scale, never a literal length — which is + how the layout system stays responsive without a single media query of its own. + + + +
+

Container types

+

+ Every page carries exactly one of these. Full-bleed is the absence of all four, never a + w-full. Within one band the class is the same everywhere it must align — the + scrolling body and its sticky action bar — or the footer's buttons drift right of the + form they submit. +

+ +
+
+
+ {{ container.type }} + .{{ container.class }} + {{ container.width }} +
+ + +
+
+
+ +

{{ container.use }}

+

{{ container.why }}

+ + {{ container.token }} + +
+
+
+ + +
+

The page shape

+

+ Three levels, each owning one decision: the boundary sets the page's top inset, the parent + section spaces the sections inside it, and each of those spaces its own parts. The page stack + itself never carries a vertical gap — it holds the heading and one element below it. Every page + carries the parent section whether it holds one section or seven, so the shape is the same to + read and a second section needs no rework. +

+ +
+ + +
+

+ The boundary is not part of the measure +

+

+ A measure describes content width — that is the one job it has. So when a page carries + its own boundary on the same block as the cap, the cap grows by exactly the inset it now + contains. Which means a page can gain or lose its own boundary — pick up a tab bar, drop one — + without moving a pixel, and no page has to know which shape it is in. +

+ +
+ + +
+

Tokens

+

+ Every one is a reference, never a length. That is what makes the layout scale inherit the + spacing scale's breakpoints instead of restating them: --spacing-lg is 1rem and + then 1.5rem from sm, and var() is substituted at use time on the + element, so the boundary follows without owning a breakpoint map. +

+ +
+
+

{{ label }}

+ + + + + + + +
+ {{ row.name }} + + {{ row.value }} +
+
+
+
+ + +
+

Two rhythm steps, nothing else

+

+ The section step separates sections of a parent; the group step separates the parts inside one + section. A gap is right on the parent and inside a section, and wrong on the page stack: within + the parent every child is a section, so one rule spaces them all, while on the page + stack the heading and the parent are different kinds of thing. +

+ +
+
+
+
+
+
+
+
+
+
+

+ The wide gap is --layout-section-gap; the tight one inside each band is + --layout-group-gap. +

+
+
+ ` + }) +} diff --git a/packages/theme/dist/v4/globals.css b/packages/theme/dist/v4/globals.css index e3a436b00..b4246a9e0 100644 --- a/packages/theme/dist/v4/globals.css +++ b/packages/theme/dist/v4/globals.css @@ -691,6 +691,16 @@ --z-input-field: 1; --z-input-popup: 10; --z-input-overlay: 1100; + --layout-boundary-inline: var(--spacing-lg); + --layout-boundary-end: var(--spacing-lg); + --layout-boundary-start: var(--spacing-lg); + --layout-section-gap: var(--spacing-xl); + --layout-group-gap: var(--spacing-md); + --layout-measure: var(--container-7xl); + --layout-measure-focused: var(--container-4xl); + --layout-measure-form: var(--container-4xl); + --layout-measure-form-create: var(--container-5xl); + --layout-measure-control: var(--container-3xs); --text-big-number-md-font-size: 1.25rem; --text-big-number-md-line-height: 1.25; --text-big-number-md-font-weight: 400; @@ -1283,6 +1293,63 @@ .text-link { transition: none; } } +@utility layout-boundary { + padding-inline: var(--layout-boundary-inline); + padding-block-start: var(--layout-boundary-start); + padding-block-end: var(--layout-boundary-end); +} + +@utility layout-boundary-inline { + padding-inline: var(--layout-boundary-inline); +} + +@utility layout-section-start { + margin-block-start: var(--layout-boundary-start); + &:first-child { margin-block-start: 0; } +} + +@utility layout-group-start { + margin-block-start: var(--layout-group-gap); + &:first-child { margin-block-start: 0; } +} + +@utility layout-column { + --layout-column-measure: var(--layout-measure); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-column-focused { + --layout-column-measure: var(--layout-measure-focused); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-column-form { + --layout-column-measure: var(--layout-measure-form); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-form-create { + --layout-column-measure: var(--layout-measure-form-create); + --layout-measure-control: var(--container-md); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-field-control { + &:not(#\#) { display: flex; flex: 1 1 0%; justify-content: flex-end; max-width: var(--layout-measure-control); } +} + @utility bg-surface-0 { background-color: var(--surface-0); } @utility text-surface-0 { color: var(--surface-0); } @utility border-surface-0 { border-color: var(--surface-0); } diff --git a/packages/theme/dist/v4/globals.scss b/packages/theme/dist/v4/globals.scss index e3a436b00..b4246a9e0 100644 --- a/packages/theme/dist/v4/globals.scss +++ b/packages/theme/dist/v4/globals.scss @@ -691,6 +691,16 @@ --z-input-field: 1; --z-input-popup: 10; --z-input-overlay: 1100; + --layout-boundary-inline: var(--spacing-lg); + --layout-boundary-end: var(--spacing-lg); + --layout-boundary-start: var(--spacing-lg); + --layout-section-gap: var(--spacing-xl); + --layout-group-gap: var(--spacing-md); + --layout-measure: var(--container-7xl); + --layout-measure-focused: var(--container-4xl); + --layout-measure-form: var(--container-4xl); + --layout-measure-form-create: var(--container-5xl); + --layout-measure-control: var(--container-3xs); --text-big-number-md-font-size: 1.25rem; --text-big-number-md-line-height: 1.25; --text-big-number-md-font-weight: 400; @@ -1283,6 +1293,63 @@ .text-link { transition: none; } } +@utility layout-boundary { + padding-inline: var(--layout-boundary-inline); + padding-block-start: var(--layout-boundary-start); + padding-block-end: var(--layout-boundary-end); +} + +@utility layout-boundary-inline { + padding-inline: var(--layout-boundary-inline); +} + +@utility layout-section-start { + margin-block-start: var(--layout-boundary-start); + &:first-child { margin-block-start: 0; } +} + +@utility layout-group-start { + margin-block-start: var(--layout-group-gap); + &:first-child { margin-block-start: 0; } +} + +@utility layout-column { + --layout-column-measure: var(--layout-measure); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-column-focused { + --layout-column-measure: var(--layout-measure-focused); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-column-form { + --layout-column-measure: var(--layout-measure-form); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-form-create { + --layout-column-measure: var(--layout-measure-form-create); + --layout-measure-control: var(--container-md); + margin-inline: auto; + width: 100%; + max-width: var(--layout-column-measure); + &:is(.layout-boundary, .layout-boundary-inline) { max-width: calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline)); } +} + +@utility layout-field-control { + &:not(#\#) { display: flex; flex: 1 1 0%; justify-content: flex-end; max-width: var(--layout-measure-control); } +} + @utility bg-surface-0 { background-color: var(--surface-0); } @utility text-surface-0 { color: var(--surface-0); } @utility border-surface-0 { border-color: var(--surface-0); } diff --git a/packages/theme/package.json b/packages/theme/package.json index 5a7708f1e..f1d55e709 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -44,6 +44,7 @@ ".": "./dist/v4/globals.css", "./animations": "./src/tokens/primitives/animations/animate.js", "./colors": "./src/tokens/primitives/colors/colors.js", + "./layouts": "./src/tokens/semantic/layouts.data.js", "./texts": "./src/tokens/semantic/texts.data.js", "./theme-colors": "./src/scripts/compile-theme.js" } diff --git a/packages/theme/src/scripts/build-tokens.mjs b/packages/theme/src/scripts/build-tokens.mjs index 7a38e6411..1b6f9c93c 100644 --- a/packages/theme/src/scripts/build-tokens.mjs +++ b/packages/theme/src/scripts/build-tokens.mjs @@ -14,6 +14,10 @@ * typography, breakpoints, animations, …) * - `tokens/semantic/{containers,spacings,texts}.data.js` * responsive-shaped semantic tokens + * - `tokens/semantic/layouts.data.js` + * layout tokens (all derived from --spacing-* / + * --container-*) + the `@utility` blocks that + * build the container system on them * - `tokens/primitives/animations/{animate,keyframes}.js` * animation utilities (`--animate-*`) + `@keyframes` * definitions + extra CSS (transform-origin) for @@ -32,6 +36,7 @@ import { breakpoints } from '../tokens/primitives/breakpoints.js'; import { compilePrimitivesVars } from './compile-primitives.js'; import { compileThemeCss, compileThemeVars } from './compile-theme.js'; import { containersData } from '../tokens/semantic/containers.data.js'; +import { layoutsData, layoutsUtilities } from '../tokens/semantic/layouts.data.js'; import { spacingsData } from '../tokens/semantic/spacings.data.js'; import { textsData } from '../tokens/semantic/texts.data.js'; import { zIndicesData } from '../tokens/semantic/z-indices.data.js'; @@ -92,6 +97,7 @@ const buildFlatModel = () => ({ containers: flattenSingleValue(containersData, (k) => `--container-${k}`), spacings: flattenSingleValue(spacingsData, (k) => `--${k}`), zIndices: flattenSingleValue(zIndicesData, (k) => `--${k}`), + layouts: flattenSingleValue(layoutsData, (k) => `--${k}`), texts: flattenBundle(textsData), }); @@ -234,6 +240,33 @@ const emitSemanticColorUtilities = () => { * interactive element loses the hand cursor unless a component overrides it. * Disabled controls keep the default cursor. */ +/** + * Emit a map of `@utility` blocks. A string value is a plain declaration; an object + * value is a nested rule keyed by a literal selector (`&:first-child { … }`), the way + * the typography utilities nest their `&:hover`. + */ +const emitUtilities = (utilities) => + Object.entries(utilities) + .map(([name, decls]) => { + const body = Object.entries(decls) + .map(([prop, value]) => + typeof value === 'object' + ? ` ${prop} { ${formatStateDecls(value)}; }` + : ` ${prop}: ${value};`, + ) + .join('\n'); + return `@utility ${name} {\n${body}\n}`; + }) + .join('\n\n'); + +/** + * The container system as `@utility` blocks — same reason as the typography + * utilities: `@layer components` classes are opaque to the variant resolver, and a + * layout class is exactly the kind a consumer wants behind a variant + * (`md:layout-column`). + */ +const emitLayoutUtilities = () => emitUtilities(layoutsUtilities); + const emitBaseLayer = () => [ '@layer base {', @@ -310,6 +343,7 @@ const emitCssV4 = () => { ...(m.containers._ || {}), ...(m.spacings._ || {}), ...(m.zIndices._ || {}), + ...(m.layouts._ || {}), ...(m.texts._ || {}), }; @@ -319,6 +353,7 @@ const emitCssV4 = () => { ...(m.containers[bp] || {}), ...(m.spacings[bp] || {}), ...(m.zIndices[bp] || {}), + ...(m.layouts[bp] || {}), ...(m.texts[bp] || {}), }; if (Object.keys(merged).length === 0) continue; @@ -366,6 +401,8 @@ const emitCssV4 = () => { '', emitTextUtilities(), '', + emitLayoutUtilities(), + '', emitSemanticColorUtilities(), '', emitBaseLayer(), diff --git a/packages/theme/src/tokens/README.md b/packages/theme/src/tokens/README.md index eaca5a574..5e2165927 100644 --- a/packages/theme/src/tokens/README.md +++ b/packages/theme/src/tokens/README.md @@ -179,7 +179,31 @@ export const spacingsData = { } ``` -`_` is the base value emitted in `:root`; the breakpoint keys (`sm`, `md`, `lg`, `xl`, `2xl`) become media-query overrides. +`_` is the base value emitted in `:root`; the breakpoint keys (`sm`, `md`, `lg`, `xl`, `2xl`) become media-query overrides. A bare string is accepted as shorthand for `{ _: value }`. + +### Derived groups (`layouts.data.js`) + +`semantic/layouts` is a **derived** group: every value is a `var()` reference to `--spacing-*` / +`--container-*`, never a literal length. + +```js +// tokens/semantic/layouts.data.js +export const layoutsData = { + 'layout-boundary-inline': 'var(--spacing-lg)', + 'layout-measure': 'var(--container-7xl)' + // … +} +``` + +That indirection is the whole point, and it is why these carry no breakpoint map of their own: the +spacing scale is already responsive (`--spacing-lg` is `1rem`, then `1.5rem` from `sm`), and a `var()` +is substituted at **use** time on the element — so a layout token follows the breakpoint override for +free. Giving it its own map would duplicate the spacing scale into layout and let the two drift. + +The group also ships `layoutsUtilities`, the container-system classes (`.layout-column`, +`.layout-boundary`, …) emitted as Tailwind v4 `@utility` blocks so they support variants +(`md:layout-column`). Utilities are shaped exactly like `illustrationsUtilities`: a string value is a +declaration, an object value is a nested rule keyed by a literal selector (`'&:first-child'`). --- diff --git a/packages/theme/src/tokens/semantic/layouts.data.js b/packages/theme/src/tokens/semantic/layouts.data.js new file mode 100644 index 000000000..904db05d6 --- /dev/null +++ b/packages/theme/src/tokens/semantic/layouts.data.js @@ -0,0 +1,254 @@ +/** + * Layout tokens — the console's container system. + * + * Three decisions, expressed once: how far content sits from the app chrome + * (BOUNDARY), how far things sit from each other (RHYTHM), and how wide a reading + * column may get (MEASURE). + * + * A DERIVED GROUP: every value here is a `var()` reference to `--spacing-*` / + * `--container-*`. Never write a literal length in this file. That indirection is the + * whole point — the spacing scale is already fluid (`--spacing-lg` is 1rem, then + * 1.5rem from `sm`), and `var()` is substituted at USE time on the element, so a + * layout token follows the breakpoint override without owning a breakpoint map of its + * own. Giving these their own maps would duplicate the spacing scale into layout and + * let the two drift. + */ +export const layoutsData = { + // BOUNDARY — content ↔ app chrome. + 'layout-boundary-inline': 'var(--spacing-lg)', + 'layout-boundary-end': 'var(--spacing-lg)', + 'layout-boundary-start': 'var(--spacing-lg)', + + /* + * RHYTHM — two levels, nothing else. + * + * THE PAGE SHAPE. The page stack never carries a vertical `gap`. It holds the + * heading and ONE element below it, and that element carries the BAND step: + * + *
+ * + *
the bands + *
+ *
+ *
+ *
+ * + * So there are exactly three levels, each owning one decision: the BOUNDARY sets the + * page's top inset, the PARENT SECTION spaces the sections inside it with + * --layout-section-gap, and each of those sections spaces its own parts with + * --layout-group-gap (its title over its card, its controls over the table they + * narrow). Every page carries the parent section, whether it holds one section or + * seven, so the shape is the same to read and a second section needs no rework. + * + * TWO TOKENS, TUNED SEPARATELY. The space above the first section belongs to the + * BOUNDARY (--layout-boundary-start) and the space between sections belongs to the + * PARENT (--layout-section-gap), so either moves without the other. Today the + * section step is one stop larger than the boundary step: the page opens tight under + * its heading and then separates its sections more firmly than the parts inside any + * one of them. Retune either here and every page follows — no page restates a step. + * + * A `gap` is right on the parent section and inside a section, and wrong on the page + * stack: within the parent every child IS a section, so one rule spaces them all; on + * the page stack the heading and the parent are different kinds of thing. + * `.layout-group-start` covers the one case a parent cannot: a group whose parts are + * already direct siblings of the page stack. + */ + 'layout-section-gap': 'var(--spacing-xl)', // between sections of a parent + 'layout-group-gap': 'var(--spacing-md)', // within a band + + /* + * MEASURE — the widest a page column may get, by context. Four values, because the + * reason a column is capped differs with how wide its payload actually is. + * + * DATA pages (lists, detail dashboards) are capped so a table that runs to the + * viewport edge on an ultrawide screen doesn't put its row actions a head-turn away + * from the name that identifies the row. They still want every pixel they can get: + * more columns visible is the point. + * + * FOCUSED pages (home, the create flows) are one task, but that task still goes + * multi-column — home is a rail plus a card grid — so they stay wide enough for + * those bands to breathe, and no wider. + * + * FORM pages (settings, in-page edit forms) are a single stacked column of fields. + * Past ~1200px the extra width lands entirely inside the controls: a label sits at + * the far left of a 1600px row from the input it names, and the eye has to travel + * the whole measure to pair them. + */ + 'layout-measure': 'var(--container-7xl)', // 1620px — data pages + 'layout-measure-focused': 'var(--container-4xl)', // 1024px — home + 'layout-measure-form': 'var(--container-4xl)', // 1024px — settings, forms + 'layout-measure-form-create': 'var(--container-5xl)', // 1192px — create flows + + /* + * MEASURE (control) — the widest the *right side* of an item-group field row may + * get. An ItemGroup row is two columns: the content names the field (title + + * guidance) on the left, the actions hold the control on the right. That right side + * is `flex-1` + `justify-end`, so this cap is what decides where the control + * actually sits — raise it and the control grows leftward, toward the label that + * names it; lower it and the control pins to the right edge with the gap opening up + * in between. + * + * The default is deliberately tight: a settings row reads as "name → current value", + * and a narrow value column keeps a scannable right edge down the whole card. A + * create form inverts that priority — the fields ARE the page's payload, several are + * radio blocks carrying their own descriptions, and the user is filling them in + * rather than scanning them — so the create band opens this up (see + * `.layout-form-create`). One token, retuned per band, instead of the cap being + * re-typed on every row. + */ + 'layout-measure-control': 'var(--container-3xs)' // 256px — settings rows +} + +/* + * --layout-column-measure is deliberately NOT a token: it is a per-class local that + * each column utility declares and the shared geometry below reads, so a fifth column + * class inherits the whole behaviour by naming one measure. + */ +const COLUMN_MEASURE = { + 'layout-column': 'var(--layout-measure)', + 'layout-column-focused': 'var(--layout-measure-focused)', + 'layout-column-form': 'var(--layout-measure-form)', + 'layout-form-create': 'var(--layout-measure-form-create)' +} + +/** + * A centered column at the measure — every page carries one of these, list pages + * included. Full-bleed is the absence of all four classes, not a `w-full`. + * + * THE BOUNDARY IS NOT PART OF THE MEASURE. A `padded` page gets its boundary from the + * app shell, on the scroll box OUTSIDE the capped block, so the measure lands as + * CONTENT width. A page that carries the boundary ITSELF puts it on the same block as + * the measure, and there `box-sizing: border-box` makes the cap swallow the inset: + * 1620px of cap minus 24px a side is a 1572px content column, 48px narrower than the + * same measure gives a padded page. That is the measure describing something other + * than content, which is the one job it has. + * + * So when the boundary rides along, the cap grows by exactly the inset it now + * contains, and both shapes resolve to the same content column at every viewport. + * Which means a page can gain or lose its own boundary — pick up a tab bar, drop one, + * move from padded to self-padded — without moving a pixel. + * + * The widening nests HERE, on the column, rather than on the boundary utilities. + * Specificity is (0,2,0) either way, so correctness does not decide it; three things + * do. Both declarations that can set `max-width` stay in one block, in reading order. + * `.layout-boundary` stays what its name says — three padding declarations — instead + * of shipping a four-way `:is()` into every consumer that uses it with no column + * anywhere. And a fifth column class still costs one line, because these are generated + * from COLUMN_MEASURE and the nested rule comes along with it. + */ +const columnUtility = (measure, extra) => ({ + '--layout-column-measure': measure, + ...extra, + 'margin-inline': 'auto', + width: '100%', + 'max-width': 'var(--layout-column-measure)', + '&:is(.layout-boundary, .layout-boundary-inline)': { + 'max-width': 'calc(var(--layout-column-measure) + 2 * var(--layout-boundary-inline))' + } +}) + +export const layoutsUtilities = { + /* + * The page boundary. Padding, never margin: a top margin on an `h-full` child of a + * padded scroll box overflows by exactly the margin (h-full resolves against the + * parent's content box, the margin sits outside it) and the overflow lands as a + * silent clip at the bottom of a table. + */ + 'layout-boundary': { + 'padding-inline': 'var(--layout-boundary-inline)', + 'padding-block-start': 'var(--layout-boundary-start)', + 'padding-block-end': 'var(--layout-boundary-end)' + }, + + /* + * The inline half of the boundary, alone — for a band that takes the page's side + * inset but owns its own vertical padding: a create flow's sticky action bar, whose + * `py` is a bar height, not a page boundary. Naming it is what lets the MEASURE rule + * above see a self-inset band and treat it exactly like the scrolling body it sits + * under, which is the whole point of a sticky bar. + */ + 'layout-boundary-inline': { + 'padding-inline': 'var(--layout-boundary-inline)' + }, + + /* + * The BOUNDARY step, as a margin — carried by the PARENT SECTION that sits directly + * below the heading. Exactly ONE element per page stack carries it. Never put it on + * a section INSIDE the parent: `gap` and `margin` both apply in a flex column, so + * the two add up and that section lands at twice the step. + * + * The `:first-child` rule zeroes the margin when the element that carries it happens + * to render first (a page with no heading; a stack whose opening band is + * conditional) — the boundary's padding is that band's top space, so the class is + * safe to carry unconditionally. This is what makes the margin model safe when the + * band ABOVE is conditional: a module list renders its controls row only when it has + * rows, so on an empty account the empty-state band becomes the first child and + * opens at exactly the boundary step. `v-if` bands are absent from the DOM, so + * `:first-child` reads the rendered truth. + * + * `margin-block-start` (not `margin-top`) to stay on the logical axis the boundary + * tokens use. Safe here because these stacks are flex containers, where a child's + * margin never collapses out of its parent. + */ + 'layout-section-start': { + 'margin-block-start': 'var(--layout-boundary-start)', + '&:first-child': { 'margin-block-start': '0' } + }, + + /* + * The GROUP step, as a margin — the fallback for a group whose parts are already + * direct siblings of the page stack, where a wrapper is the wrong answer. The band + * element normally IS that wrapper (`gap: var(--layout-group-gap)`), and that is the + * form to prefer: it names the group in the markup. + */ + 'layout-group-start': { + 'margin-block-start': 'var(--layout-group-gap)', + '&:first-child': { 'margin-block-start': '0' } + }, + + ...Object.fromEntries( + Object.entries(COLUMN_MEASURE).map(([name, measure]) => [ + name, + columnUtility( + measure, + // The create band retunes the control cap for everything inside it. On a + // 1192px column a 256px control side left every input pinned to the far right, + // a head-turn away from the label naming it; 472px pulls the control back + // toward its label and gives the radio blocks room to read. + name === 'layout-form-create' ? { '--layout-measure-control': 'var(--container-md)' } : undefined + ) + ]) + ), + + /* + * The control side of an item-group field row: the same geometry every settings row + * hand-repeated, with the cap coming from --layout-measure-control so a band retunes + * all of its rows at once. + * + * `flex-1` + `justify-end` rather than a fixed width: the row stays two-column at + * every viewport (the control side yields to a long field name instead of wrapping), + * and controls inside still pass `w-full` to fill the cap. + * + * THE `:not(#\#)` IS LOAD-BEARING, and it is the one place in this file that needs a + * specificity hack. This class is applied to a component whose own root already + * carries `shrink-0`, and Vue merges both class lists onto one element. As plain + * unlayered CSS this rule won this tie; as an `@utility` it sorts BEFORE the core + * utilities, so `shrink-0` would win and the control side could no longer yield to a + * long field name. `#\#` is an id no element can carry, so the selector always + * matches while reading (1,1,0) — Tailwind's own important-strategy idiom. It stays + * variant-safe (`.md\:layout-field-control:not(#\#)`), unlike an `&:is(…)` bump. + * Applied to this utility ONLY: the other eight have no live conflict, and the hack + * would needlessly outrank legitimate consumer overrides. + */ + 'layout-field-control': { + '&:not(#\\#)': { + display: 'flex', + flex: '1 1 0%', + 'justify-content': 'flex-end', + 'max-width': 'var(--layout-measure-control)' + } + } +} + +export default { layoutsData, layoutsUtilities } diff --git a/packages/webkit/catalog.json b/packages/webkit/catalog.json index 42426061e..6f3de36e1 100644 --- a/packages/webkit/catalog.json +++ b/packages/webkit/catalog.json @@ -395,6 +395,17 @@ "--inset-shadow-2xs", "--inset-shadow-sm", "--inset-shadow-xs", + "--layout-boundary-end", + "--layout-boundary-inline", + "--layout-boundary-start", + "--layout-column-measure", + "--layout-group-gap", + "--layout-measure", + "--layout-measure-control", + "--layout-measure-focused", + "--layout-measure-form", + "--layout-measure-form-create", + "--layout-section-gap", "--leading-loose", "--leading-none", "--leading-normal", @@ -1124,6 +1135,19 @@ "--inset-shadow-sm", "--inset-shadow-xs" ], + "layout": [ + "--layout-boundary-end", + "--layout-boundary-inline", + "--layout-boundary-start", + "--layout-column-measure", + "--layout-group-gap", + "--layout-measure", + "--layout-measure-control", + "--layout-measure-focused", + "--layout-measure-form", + "--layout-measure-form-create", + "--layout-section-gap" + ], "leading": [ "--leading-loose", "--leading-none",