diff --git a/.claude/AGENTS.md b/.claude/AGENTS.md index cb179848d..166a99bc4 100644 --- a/.claude/AGENTS.md +++ b/.claude/AGENTS.md @@ -117,7 +117,7 @@ For onboarding content, prioritize Storybook docs pages under `Foundations`. ## 11) Webkit Layer Components (mandatory for agents) -**Scope:** any file under `packages/webkit/src/components/webkit/**` (categories `actions`, `content`, `data`, `feedback`, `inputs`, `layout`, `navigation`, `overlay`, `utils`). +**Scope:** any file under `packages/webkit/src/components/webkit/**` (categories `actions`, `code`, `content`, `data`, `documentation`, `feedback`, `inputs`, `layout`, `marketing`, `navigation`, `overlay`, `templates`, `utils` — the registry is [`.claude/hooks/component-categories.mjs`](./hooks/component-categories.mjs)). ### 11.1) Sources of truth (read first) diff --git a/.claude/docs/CONTAINERS.md b/.claude/docs/CONTAINERS.md new file mode 100644 index 000000000..cfa285258 --- /dev/null +++ b/.claude/docs/CONTAINERS.md @@ -0,0 +1,322 @@ +# The framed grid — Azion's industrial page language + +The page-composition language shared by **Hub**, **Docs**, and **Site**: a fluid hero band, a +centered column framed by vertical rules, and a stack of self-contained modules divided by +hairlines. Nothing floats, nothing is rounded, and **no line is ever drawn twice**. The result is +the drawn-grid, technical-drawing look — the *industrial* register of the Azion brand. + +Reference implementations live in +[`apps/webkit-sample/src/site/components/`](../../apps/webkit-sample/src/site/components/) and +[`.../site/docs/components/`](../../apps/webkit-sample/src/site/docs/components/); the layout primitives — +shared by Site, Hub and Docs — are in +[`shared/ui/layout/`](../../apps/webkit-sample/src/shared/ui/layout/). + +For the token-level vocabulary (weights, border colors, radii) and how individual **webkit +components** draw their own edges, see [§ Vocabulary](#vocabulary) below and +[`DESIGN.md`](./DESIGN.md). + +--- + +## The one-frame principle + +The whole page is **one continuous frame**. Every edge in it is owned by exactly one element — the +neighbour on the other side of that edge draws nothing. This is the rule the entire language hangs +on; a doubled line is the one unmistakable failure. + +| Edge | Owned by | Utility | +| --- | --- | --- | +| Top rule of the page body | The hero band | `border-b` (full-bleed) | +| Left + right rules | The content column | `border-x` | +| Bottom rule | The footer | `border-t` | +| Rule between two modules | The **lower** module | `border-t` | +| Rule under a module header | The header row | `border-b` | +| Left/right edge of any module | *Nobody* — it's the column's `border-x` | — | +| Internal rules of a cell grid | The grid's `gap-px` showing its own background | — | + +Two consequences you have to actively remember: + +- **The first module in a column passes `:divided="false"`.** Its top edge is already the hero's + `border-b`. +- **Modules never carry side borders.** They are edge-to-edge inside the column, which is why the + column defaults to `padded: false` and each module owns its own padding. + +--- + +## The three-layer skeleton + +```vue + + + + + + + … + … + + … + + + + +``` + +Live: [`WebkitHub.vue:200-363`](../../apps/webkit-sample/src/hub/views/WebkitHub.vue#L200-L363), +[`DocsHome.vue:244-539`](../../apps/webkit-sample/src/site/docs/components/DocsHome.vue#L244-L539), +[`HubFoundations.vue:209-269`](../../apps/webkit-sample/src/hub/components/HubFoundations.vue#L209-L269). + +--- + +## 1. The hero rule — `Hero` + +[`hero.vue`](../../packages/webkit/src/components/marketing/hero/hero.vue) + +A **full-bleed** band. Its bottom hairline runs the entire viewport width; the framed column hangs +below it. That contrast — one edge-to-edge rule above a narrower framed column — is what makes the +frame read as *drawn on* the page rather than as a card sitting on it. + +```html +
+ +
+ +
+
+``` + +**`kind="screen"` fills exactly one screen.** It subtracts `--banner-offset` from `100dvh`, so a banner +mounted under fixed chrome still measures one viewport. The page passes the chrome height in: + +```html + +class="[--banner-offset:calc(var(--bar-height,3.5rem)+var(--page-bar-height,3rem))]" +``` + +Unset ⇒ `0`, so a banner that owns the whole viewport needs nothing. +([`DocsGetStarted.vue:368`](../../apps/webkit-sample/src/site/docs/components/DocsGetStarted.vue#L368)) + +**`#background` is z-0, copy is z-10.** A backdrop is named on the container +(`banner=""`) and resolved from the registry +([`banners/index.js`](../../apps/webkit-sample/src/shared/ui/banners/index.js)); the `#background` +slot is the escape hatch for a one-off, and wins over the prop when both are given. + +**Every Site hero carries `banner="dot-grid"`.** It is azion.com's own hero texture — a crisp 2px +square at every intersection of a 48px lattice, flat across the band — measured off the source's +render rather than eyeballed, and it is what makes the six `/site` pages read as one site. Hub and +Docs still open on plain `--bg-canvas`. + +The registry today: `dot-grid` (the Site hero texture), `map` (the pixel world map), `pixelate` +(the accent-lit grid with a travelling wave). + +When a page dresses a hero with something heavier than a texture, the recipe is layered bottom-up: + +1. The artwork at `opacity-60`…`opacity-80`. +2. A **radial mask** fading it at the edges: `mask-[radial-gradient(ellipse_at_center,black,transparent_75%)]`. +3. A **scrim** dimming it under the headline — e.g. a `bg-gradient-to-b … to-[var(--bg-canvas)]` + fade handing the band off to the module below. + +A flat texture skips all three: `dot-grid` covers ~0.17% of the band, so there is nothing for the +headline to compete with, and the ink is a 22% mix of `--text-default` so the field carries the +same step from the ground on either theme instead of being tuned for the dark one. + +([`WebkitHub.vue:199-202`](../../apps/webkit-sample/src/hub/views/WebkitHub.vue#L199-L202), +[`AzionHome.vue:211-221`](../../apps/webkit-sample/src/site/components/AzionHome.vue#L211-L221)) + +**Hero copy anatomy** — left-aligned, always in this order +([`PageHeader.vue`](../../apps/webkit-sample/src/shared/ui/layout/PageHeader.vue)): + +| Part | Style | +| --- | --- | +| Eyebrow | `Overline` (uppercase, tracking baked in) — or an orange dot + `text-overline-sm` on the Site | +| Headline | `text-heading-2xl`, `text-balance`, capped at `--container-4xl` | +| Description | `text-body-lg`, `text-pretty`, capped at `--container-2xl` | +| Actions | Primary `Button` + secondary, or a `ContrastBanner` copy-prompt pill | + +The Site hero closes with a **capability strip** — a 2/3/5-column grid separated from the copy by +`border-t border-[var(--border-muted)]`, bold lead-in plus one supporting line per cell. +([`AzionHome.vue:251-260`](../../apps/webkit-sample/src/site/components/AzionHome.vue#L251-L260)) + +--- + +## 2. The container rule — `SectionContainer` + +[`SectionContainer.vue`](../../apps/webkit-sample/src/shared/ui/layout/SectionContainer.vue) + +The framed column. Centered, capped, and carrying **only** `border-x`. + +```html +
+``` + +- **`padded` defaults to `false`.** An edge-to-edge stack of modules each own their padding; adding + column padding would double it and pull the modules off the frame. Pass `padded` only for a plain + prose column with no modules. +- **`bordered`** can be turned off for a column that sits inside another frame. + +--- + +## 3. The module rule — `SectionModule` + +[`SectionModule.vue`](../../apps/webkit-sample/src/shared/ui/layout/SectionModule.vue) + +The "lego brick". A `
` with a header row divided from its body by a hairline, and divided +from the module above by a hairline. Stacked in a `SectionContainer`, the modules read as bricks in +one continuous frame. + +```html +
+
…
+
…
+
+``` + +| Prop | When to change it | +| --- | --- | +| `divided` | `false` on the **first** module in a column — its top edge is the hero's `border-b`. | +| `padded` | `false` when the body is an edge-to-edge `CardGrid`, so the grid's rules meet the frame with no gutter. | +| `#header` | Replaces the default title row entirely. | +| `#actions` | Trailing CTAs inside the default header row. | + +--- + +## 4. The hairline box grid — `CardGrid variant="divider"` + +[`CardGrid.vue`](../../apps/webkit-sample/src/shared/ui/layout/CardGrid.vue) + +The signature industrial module: a grid whose internal rules are **gaps**, not borders. + +```html +
+ +
…
+
+``` + +`gap-px` lets the wrapper's background colour show through as 1px internal rules. **Children must +fill their own background** (`bg-[var(--bg-canvas)]`) or the whole cell goes border-coloured. + +The divider variant carries **no perimeter border** — that is what lets it sit flush inside a +`SectionContainer` whose `border-x` already owns the outer edges. Set `dividerColor="muted"` for +rules one step back. + +The `gap` variant is the other mode: self-contained cards with their own border, radius and +background, separated by real gutters. + +**Cells are square.** `ComponentGridCell` uses `rounded-[var(--shape-flat)]` — the interactive +component showcase grid is deliberately unrounded, with a dashed `--primary` ring and a corner label +pinned at `top-0 left-0` on hover/focus-within. +([`ComponentGridCell.vue:33-49`](../../apps/webkit-sample/src/hub/components/ComponentGridCell.vue#L33-L49)) + +--- + +## 5. The registration frame — `FrameBox` + +[`FrameBox.vue`](../../apps/webkit-sample/src/shared/ui/layout/FrameBox.vue) + +The most literal piece of the industrial language: a thin bordered box with **crosshair registration +marks** straddling each corner, plus an optional vertical hatch texture. Used for the final CTA +block. + +- **Marks** (`marks`, default `true`) — an 11px crosshair per corner, centered *on* the corner with a + half-size translate so it straddles the border line. Two 1px spans in `--border-default`. +- **Hatch** (`hatch`, default `false`) — vertical rules every `--spacing-lg`, drawn as a + `repeating-linear-gradient` in `--border-muted` at `opacity-40`, faded at the edges with + `mask-[radial-gradient(ellipse_at_center,black,transparent_85%)]`. + +```html +
+
+ +
+
+``` + +--- + +## Vocabulary + +The token layer everything above resolves to. + +### Weights + +| Token | Value | Role | +| --- | --- | --- | +| `--border-width-default` (= `--border-1`) | `0.8px` | Component-level hairline (opt-in, see [drift](#known-issues)). | +| `--border-2` | `2px` | Accent bar — painted as a **filled element**, never a border. | + +The page layer uses the bare `border` / `border-x` / `border-t` utilities throughout, which is +Tailwind's **1px**. + +### Colors + +| Token | Light | Dark | Role | +| --- | --- | --- | --- | +| `--border-default` | `#00000014` | `#FFFFFF1A` | The frame: hero rule, column rules, module dividers, grid gaps. | +| `--border-muted` | `#00000014` | `#FFFFFF0D` | One step back: rules *inside* a module, capability strips, `FrameBox` perimeter + hatch. | +| `--border-strong` | `#000000` | `#FFFFFF` | Pressed / active edge. | +| `--border-selected` | `#F3652B` | `#F3652B` | Selection (Azion orange, both modes). | + +> **`default` and `muted` are identical in light mode** (`#00000014`); only dark mode separates them. +> Pick by role and verify in dark — light mode cannot show you the mistake. + +### Radii + +`--shape-flat` `0` (grid cells, bands) · `--shape-elements` `6px` (inputs, chips) · +`--shape-button` `6px` · `--shape-card` `8px` — **the ceiling**. Nothing structural is pill-shaped. + +### Column widths + +Both containers key into [`container.js`](../../packages/theme/src/tokens/primitives/shape/container.js) +— a geometric scale anchored at `3xs` 256px and `7xl` 1620px, every neighbour ~+16.6% apart. + +--- + +## Rules of the language + +- **One edge between two things.** Give it to one side; the other draws nothing. +- **First module in a column: `:divided="false"`.** +- **Module body `:padded="false"` when it holds an edge-to-edge grid.** +- **`variant="divider"` children must fill their own background.** +- **Heroes are full-bleed; columns are capped.** Never put `border-x` on a hero or `border-b` on a + column. +- **No shadow anywhere in this language.** Shadows mean *floating* — overlays, popovers, drawers. +- **No radius above `--shape-card` (8px); grid cells are `--shape-flat`.** +- **Texture is masked, never raw.** Every ASCII field / hatch carries a radial mask and an opacity + below 1, so it never competes with copy. +- **`motion-reduce:` on every transition.** `ComponentGridCell` does this in its scoped block; inline + utilities use `motion-reduce:transition-none`. + +--- + +## Known issues + +Found while documenting; **not fixed** — each changes rendered output. + +1. **`FrameBox` is used but never imported** — + [`AzionHome.vue:550`](../../apps/webkit-sample/src/site/components/AzionHome.vue#L550) wraps the + final CTA in ``, but the script block imports only `Button`, `CardBox`, + `CardPricing`, `CodeBlock`, `PlatformIllustrations`, `PlatformShowcase`, and + `main.js` registers nothing globally. The route is live (`/site/home` → `LandingAzion` → + `AzionHome`), so Vue logs *"Failed to resolve component: FrameBox"* and the CTA renders with **no + frame, no crosshairs, no hatch**. One-line fix: + `import FrameBox from './foundations/components/layout/FrameBox.vue'`. + +2. **`maxWidth` keys don't mean the same width in the two containers.** `Hero`'s map is + shifted two steps down from `SectionContainer`'s — `max-width="7xl"` gives `--container-5xl` + (1192px) in the banner but `--container-7xl` (1620px) in the column; `"6xl"` gives 1024px vs + 1388px. `SectionContainer`'s docstring says *"match the banner above it"*, which reads as "pass + the same key" — and passing the same key does **not** align their content edges. (The banner also + adds `px-[var(--spacing-xl)]`, so some inset is intended; the two-step shift is systematic across + all five keys.) + +3. **Two hairline weights coexist.** The page layer uses bare `border` (1px); 39 webkit components + request `border-[length:var(--border-width-default)]` (0.8px). The theme sets no + `--default-border-width`, so the token is opt-in and the two sit side by side on the same screen. + +4. **`PageHeader` mixes raw values into a token system** — `mb-12`, `mb-3`, `mt-4`, `mt-6`, and + `max-w-[620px]` instead of spacing/container tokens. + +Items 2–4 need a visual-baseline regen; item 1 does not. diff --git a/.claude/hooks/component-categories.mjs b/.claude/hooks/component-categories.mjs index 2e1e73a22..ae47b77e0 100644 --- a/.claude/hooks/component-categories.mjs +++ b/.claude/hooks/component-categories.mjs @@ -7,6 +7,7 @@ export const COMPONENT_CATEGORIES = [ 'feedback', 'inputs', 'layout', + 'marketing', 'navigation', 'overlay', 'templates', diff --git a/.claude/hooks/validate-references.mjs b/.claude/hooks/validate-references.mjs index 264708494..6a3fe9607 100644 --- a/.claude/hooks/validate-references.mjs +++ b/.claude/hooks/validate-references.mjs @@ -66,9 +66,15 @@ function resolveAziontechWorkspace(specifier) { return existsSync(join(ROOT, 'packages', wsName, 'package.json')) } -function resolveRelativeImport(specifier, fromFile) { - const baseDir = dirname(resolve(fromFile)) - const target = resolve(baseDir, specifier) +const VITE_ALIASES = { + '@shared': 'apps/webkit-sample/src/shared', + '@site': 'apps/webkit-sample/src/site', + '@hub': 'apps/webkit-sample/src/hub', + '@console': 'apps/webkit-sample/src/console', + '@preview': 'apps/webkit-sample/src/preview' +} + +function resolveFileTarget(target) { const candidates = [ target, `${target}.vue`, @@ -95,6 +101,17 @@ function resolveRelativeImport(specifier, fromFile) { }) } +function resolveRelativeImport(specifier, fromFile) { + return resolveFileTarget(resolve(dirname(resolve(fromFile)), specifier)) +} + +function resolveViteAlias(specifier) { + const [head, ...rest] = specifier.split('/') + const base = VITE_ALIASES[head] + if (!base) return false + return resolveFileTarget(join(ROOT, base, ...rest)) +} + function resolveNodeModule(specifier) { const parts = specifier.split('/') const pkgName = specifier.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0] @@ -104,7 +121,8 @@ function resolveNodeModule(specifier) { 'packages/theme/node_modules', 'packages/icons/node_modules', 'apps/storybook/node_modules', - 'apps/icons-gallery/node_modules' + 'apps/icons-gallery/node_modules', + 'apps/webkit-sample/node_modules' ] return dirs.some((d) => existsSync(join(ROOT, d, pkgName))) } @@ -139,7 +157,7 @@ async function main() { let ok = false let reason = '' - if (spec.startsWith('@aziontech/webkit')) { + if (spec === '@aziontech/webkit' || spec.startsWith('@aziontech/webkit/')) { ok = resolveAziontechWebkit(spec) reason = 'no matching entry in packages/webkit/package.json#exports' } else if (spec.startsWith('@aziontech/')) { @@ -148,6 +166,9 @@ async function main() { } else if (spec.startsWith('.') || spec.startsWith('/')) { ok = resolveRelativeImport(spec, filePath) reason = 'relative path does not resolve to any file' + } else if (Object.hasOwn(VITE_ALIASES, spec.split('/')[0])) { + ok = resolveViteAlias(spec) + reason = 'vite alias does not resolve to any file' } else { ok = resolveNodeModule(spec) reason = 'package not installed in any node_modules' diff --git a/.specs/_schema.json b/.specs/_schema.json index d52d0a7eb..c5a402e3a 100644 --- a/.specs/_schema.json +++ b/.specs/_schema.json @@ -31,6 +31,7 @@ "feedback", "inputs", "layout", + "marketing", "navigation", "overlay", "templates", diff --git a/.specs/_template.md b/.specs/_template.md index 61984b230..08f81013a 100644 --- a/.specs/_template.md +++ b/.specs/_template.md @@ -1,6 +1,6 @@ --- name: -category: +category: structure: status: draft spec_version: 1