From 5a914318f592902086c4691e9a7055a7309de3a3 Mon Sep 17 00:00:00 2001 From: Gab Date: Mon, 28 Sep 2026 07:20:55 -0300 Subject: [PATCH 1/2] feat(webkit): add the band-stack marketing component --- .specs/band-stack.md | 159 ++++++++++++++++++ .../marketing/band-stack/BandStack.stories.js | 144 ++++++++++++++++ packages/webkit/.size-limit.json | 5 + packages/webkit/catalog.json | 57 +++++++ packages/webkit/package.json | 1 + .../marketing/band-stack/band-stack.test.ts | 105 ++++++++++++ .../marketing/band-stack/band-stack.vue | 77 +++++++++ 7 files changed, 548 insertions(+) create mode 100644 .specs/band-stack.md create mode 100644 apps/storybook/src/stories/components/marketing/band-stack/BandStack.stories.js create mode 100644 packages/webkit/src/components/marketing/band-stack/band-stack.test.ts create mode 100644 packages/webkit/src/components/marketing/band-stack/band-stack.vue diff --git a/.specs/band-stack.md b/.specs/band-stack.md new file mode 100644 index 000000000..f2ee288ba --- /dev/null +++ b/.specs/band-stack.md @@ -0,0 +1,159 @@ +--- +name: band-stack +category: marketing +structure: monolithic +status: implemented +spec_version: 1 +created: 2026-09-26 +last_updated: 2026-09-26 +checksum: 849b303b628b4104ccac10b54d80e9a4876f5ac61d916adb096ee801e8852485 +--- + +# Band Stack — Component Spec + +## Purpose + +A run of framed bands that follow one another down the page, each one its own registration frame sharing a hairline with the next. With `sticky` on, each band pins under the site header a step lower than the band before it, so the run piles up as the reader scrolls and every band already read stays visible as a ledge above the one being read. + +The component owns the frames, the shared hairlines and the pin offsets; the bands themselves are whatever the consumer composes into the default slot — each direct child becomes one band. It carries no copy, heading or media of its own. + +## When to use + +- For a run of peer bands — three to six `media-split`s, one per solution or capability — that should read as one sequence. +- With `sticky`, when the run is the page's centrepiece and the reader should feel each band land on the last. +- Without `sticky`, to frame a run of bands with one shared hairline between neighbours, with no pinning. + +## When NOT to use + +- For a run where the scroll position should decide which claim is open beside one media pane → use `sticky-stack`. +- For peer claims a reader jumps between at will → use `media-tabs`. +- For a single band → frame it with `frame-box` directly, or set `framed` on the `media-split`. +- For a grid of cards → use `card-grid`. + +## Related + +- `media-split` — the band this stack most often holds, typically at `size="large"`. +- `sticky-stack` — the scroll-driven sibling: one pinned frame whose open claim follows the scroll, instead of bands that pile up. +- `frame-box` — the frame each band is drawn in. +- `section-gap` — the spacer usually above the stack; pass `flush` so the first band does not draw a second rule under it. + +## Best practices + +- Keep the run to three to six bands. With `sticky` every band read stays pinned as a ledge, and a long run fills the viewport with ledges. +- Give every band the same height class (`media-split` `size="large"`, same `align`), so the ledges line up as the pile grows. +- Pass `flush` when the element directly above the stack already draws a rule (a `section-gap`, another frame), so the first band does not draw a second one. +- Give each band an opaque fill (`media-split` paints its own cells). A pinned band covers the one before it, and a transparent band shows the one underneath through it. +- Render the bands with `v-for` directly in the default slot; each direct child is one band, so a wrapper element around them collapses the run into a single band. + +## Usage + +```vue + + + +``` + +## Props + +| Prop | Type | Default | Required | JSDoc | +| -------- | --------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `sticky` | `boolean` | `false` | false | Pin each band under the site header from `lg` up, a step lower than the band before it, so the run piles up as the page scrolls. | +| `flush` | `boolean` | `false` | false | Drop the first band's top rule, for a stack sitting directly under an element that already draws one. | + +## Events + +| _none_ | — | — | + +## Slots + +| Slot | Scope | Notes | +| --------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `default` | — | The bands. Each direct child — including every child a `v-for` renders — is wrapped in its own frame and becomes one band; comment nodes are skipped. | + +## States + +- Visual states: `default` +- `data-sticky` mirrors the `sticky` prop and is what pins the bands: from `lg` up each band is `position: sticky`, its `top` the site header's height plus one `var(--spacing-md)` step per band before it. Below `lg` the bands stack in normal flow with no pinning, because a pinned pile of full-width bands leaves a phone no room to read +- `data-flush` mirrors the `flush` prop; the first band's frame then draws no top rule +- Each band is a `frame-box` with its top and bottom rules and all four registration marks. Every band after the first pulls up one pixel so neighbouring rules overlap into a single hairline, while each band still owns its top rule — the rule a pinned band shows when it covers the one before it +- The pin offset is owned by the component, not the page: the stack pins below the site header's `3.5rem` bar, and a page sets nothing to get it +- Later bands paint over earlier ones in DOM order, so the pile grows downward with no `z-index` of its own + +## Motion & Animations + +_none_ — pinning follows the scroll position; the component declares no transition or animation. + +## Tokens + +| Region | Token (DESIGN.md) | +| ----------------------------- | ------------------------------------------------------ | +| band rules and marks | `var(--border-default)` (drawn by `frame-box`) | +| pin step between bands | `var(--spacing-md)` | +| pin start (site header height) | `calc(var(--spacing) * 14)` — see Theme gaps | + +## Theme gaps + +| Figma variable | Temporary primitive | Follow-up | +| --------------------- | -------------------------------------------------------------------------- | ----------------- | +| site header height | `calc(var(--spacing) * 14)`, the same `3.5rem` `global-header` sets with `h-14` | `TODO: tokenizar` | + +## Accessibility (WCAG 2.1 AA) + +- Visible focus: not applicable to the stack; controls inside each band keep their own `focus-visible` ring. +- Keyboard map: none of its own — `Tab` moves through each band's controls in DOM order, band by band. +- ARIA: the stack is a plain `div` with no role; each band keeps its own semantics (`media-split` is a `section` named by its heading). +- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons): not applicable — the stack draws only rules; each band owns its text contrast. +- `motion-reduce:transition-none motion-reduce:transform-none` — not applicable, the stack declares no motion. Pinning is scroll position, not animation. +- Touch target — not applicable; the stack composes no control of its own. + +## Stories (Storybook) + +- Default — three `media-split` bands at `size="large"`, not pinned, so the frames and the shared hairlines are the only thing on show. +- Sticky — the same run with `sticky` and `flush` on. It sets `parameters.layout: 'fullscreen'`; a story cannot scroll itself, so the canvas shows the resting state and the reader scrolls the Docs page to see the pile form. + +## Constraints — DO NOT + + + +- Do not add props beyond the Props table above. If you need a prop that is not listed, emit `BLOCKED: missing prop ` and stop — do not invent. +- Do not add events beyond the Events table above. Same rule for slots and sub-components. +- Do not invent imports. Every `@aziontech/webkit/*` path must exist in `packages/webkit/package.json#exports`. Every relative import must resolve to a real file. Every npm package must be installed. +- Do not use HEX/RGB/HSL colors, Tailwind palette names (e.g. `bg-blue-500`), raw typography classes (e.g. `text-sm`), `any`, `@ts-ignore`, or `class` inside `defineProps`. +- Do not install or import positioning/animation libraries (`@floating-ui/*`, `popper.js`, `tippy.js`, `gsap`, `framer-motion`, `motion`, `@vueuse/motion`, `@formkit/auto-animate`, drag-drop runtimes, scroll virtualization libs). Use CSS + Vue primitives (``, ``). See `.claude/rules/dependencies.md`. +- Do not improvise animations. Every `animate-*` / `transition-*` class must come from `packages/theme/src/tokens/semantic/animations.js`; every motion-bearing class pairs with `motion-reduce:*` on the same class string; no component-local `@keyframes`. +- Do not create class presets in JavaScript (`const kindClasses = {...}`, `const sharedClasses = [...]`, `const sizeClasses = {...}`, `const rootClasses = computed(...)`). Variants live on `data-*` attributes consumed by Tailwind `data-[attr=value]:`. All utilities live inline on the root element's `class` attribute. No `