From 4a78f76d308907f72ff4b3c05cb3a0f87dc679af Mon Sep 17 00:00:00 2001 From: Gab Date: Mon, 28 Sep 2026 07:20:55 -0300 Subject: [PATCH 1/4] feat(webkit): add the ticker marketing component --- .specs/ticker.md | 153 ++++++++ .../marketing/ticker/Ticker.stories.js | 349 ++++++++++++++++++ packages/webkit/.size-limit.json | 6 + packages/webkit/catalog.json | 78 ++++ packages/webkit/package.json | 1 + .../marketing/ticker/ticker.test.ts | 141 +++++++ .../components/marketing/ticker/ticker.vue | 115 ++++++ packages/webkit/src/svg/brands/registry.ts | 2 +- 8 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 .specs/ticker.md create mode 100644 apps/storybook/src/stories/components/marketing/ticker/Ticker.stories.js create mode 100644 packages/webkit/src/components/marketing/ticker/ticker.test.ts create mode 100644 packages/webkit/src/components/marketing/ticker/ticker.vue diff --git a/.specs/ticker.md b/.specs/ticker.md new file mode 100644 index 000000000..00ce69d02 --- /dev/null +++ b/.specs/ticker.md @@ -0,0 +1,153 @@ +--- +name: ticker +category: marketing +structure: monolithic +status: implemented +spec_version: 1 +checksum: bc74ab5b07259372e2d53ea3ff45896601d49aecfbb41b675ba5430abd0303a0 +created: 2026-09-23 +last_updated: 2026-09-23 +--- + +# Ticker — Component Spec + +## Purpose + +An endlessly looping row of brand marks — the trust strip that names the companies running on the platform, and the stack strip that names the frameworks and clouds a workload already uses. The marks come from the package's own registry and are drawn in one ink, so a strip reads as a list rather than as a dozen competing palettes. + +## When to use + +- Under a hero, to name the companies a product already serves ("Trusted by mission-critical workloads"). +- To state compatibility as a list of framework, cloud and tooling marks ("Your Stack, Your Way"). +- Whenever the set of marks is longer than the row that holds it and no single mark has to be read. + +## When NOT to use + +- When every mark must be seen → use `logo-wall`, which wraps into a grid and never moves. +- When the reader chooses how far to go through browsable content → use `carousel`, which is user-driven and never advances on its own. +- For a mark a reader must act on, such as a partner directory → the strip is decorative motion, so put linked marks in a `logo-wall`. +- For one mark in its own right (a header lockup, a client story card) → use `brand`. + +## Related + +- `logo-wall` — the static, wrapping grid of consumer-supplied marks; reach for the strip only when the wall would be too long. +- `carousel` — the user-driven snap scroller; this one moves on its own and holds no essential content. +- `brand` — the Azion lockup, for the product's own mark rather than a third party's. +- `overline` — renders the `label` above the row. + +## Best practices + +- Name marks from the registry rather than passing artwork: the registry is what keeps one company's mark identical on every surface, and a name with no entry still renders as its own wordmark so a strip is never incomplete. +- Let `duration` derive itself. The component holds speed constant rather than duration, so an eleven-mark strip and a thirty-mark strip advance at the same rate; pass a number only to slow one deliberately. +- Keep `label` to the claim the row makes, not to a heading — the strip sits under a hero, and a second heading competes with the one above it. +- Use `size="small"` only where the strip is the quietest thing on the page, such as the floor of a sign-in column; `medium` is the marketing band and carries its own responsive ladder. +- Never put a call to action, a price or anything else a reader needs inside a strip. It scrolls away, and under `prefers-reduced-motion` it becomes a wrapped grid instead. + +## Usage + +```vue + + + +``` + +## Props + +| Prop | Type | Default | Required | JSDoc | +| ----------- | --------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kind` | `'plain' \| 'band'` | `'plain'` | false | Whether the strip paints its own ground; `band` fills it with the page canvas so a textured hero cannot show through the marks, and the floor still reads continuous with the band above it. | +| `marks` | `string[]` | `[]` | false | Registry names of the marks rendered in the row, in order; an unregistered name renders as its own typographic wordmark so the row stays complete. | +| `label` | `string` | `''` | false | Overline above the row, stating the claim the marks make. | +| `size` | `'small' \| 'medium'` | `'medium'` | false | Mark scale; `medium` is the marketing band and climbs 32/40/48 px by device class, `small` is a flat 24 px for a column. | +| `duration` | `number` | `0` | false | Seconds for one full pass; left at `0` it is derived from the number of marks so every strip moves at one speed. | +| `ariaLabel` | `string` | `''` | false | Accessible name for the row of marks, announced instead of an unnamed list. | + +## Events + +| _none_ | — | — | + +## Slots + +| _none_ | — | — | + +## States + +- Visual states: every mark rests at 70% opacity and lifts to full on `hover` +- `data-size` mirrors the `size` prop and is what drives the mark height, the cell width and the row gap +- `data-kind` mirrors the `kind` prop; `band` paints the page canvas and the strip's own vertical rhythm so nothing behind it shows through while the fill still matches the band it floors, `plain` leaves it transparent over whatever it sits on +- `data-duplicate` marks the second, `aria-hidden` copy of the row — the one the loop translates into place, and the one hidden outright under reduced motion +- Empty: when `marks` is empty the row renders nothing, so a page with no marks to name shows no empty band +- Reduced motion: the track stops and wraps into a centred static grid, with the duplicate row removed, so every mark stays reachable + +## Motion & Animations + +| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback | +| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | +| always (the loop) | `animate-brand-marquee` | semantic (59s default · linear infinite; the track overrides `animation-duration` from its mark count) | `motion-reduce:animate-none` (the row wraps into a static grid) | +| hover on a mark | `transition-opacity duration-fast-02 ease-productive-entrance` | inline (matches catalog) | `motion-reduce:transition-none` | + +## Tokens + +| Region | Token (DESIGN.md) | +| --------------------------------- | --------------------------------------------------- | +| typography (fallback wordmark) | `.text-heading-xxs` | +| ink (mark) | `var(--text-default)` at 70% opacity, full on hover | +| spacing (label ↔ row) | `var(--spacing-xl)` | +| spacing (row gap, medium) | `var(--spacing-4)` / `var(--spacing-8)` | +| spacing (row gap, small) | `var(--spacing-10)`, cells sized to the mark | +| spacing (reduced-motion grid gap) | `var(--spacing-md)` | + +## Theme gaps + +| Figma variable | Temporary primitive | Follow-up | +| -------------- | ------------------- | --------- | +| _none_ | — | — | + +## Accessibility (WCAG 2.1 AA) + +- Visible focus: none of its own — no mark is a focusable target, so the strip adds no tab stop and needs no ring. The loop still pauses on `:focus-within`, so anything a consumer places over it stays still while it is reached. +- Keyboard map: no arrow-key model and no tab stop — this is a list, not a composite widget. +- ARIA: the row is a `