diff --git a/.claude/docs/DESIGN.md b/.claude/docs/DESIGN.md index 57bc47e5d..f1297f9bc 100644 --- a/.claude/docs/DESIGN.md +++ b/.claude/docs/DESIGN.md @@ -258,17 +258,51 @@ bg-[var(--bg-surface)] text-[var(--text-default)] text-[var(--text-muted)] ring- | Role | Examples | | ------------- | --------------------------------------------------------------------------------------------------------- | | Brand actions | `--primary`, `--primary-contrast`, `--secondary`, `--secondary-contrast` | -| Surfaces | `--bg-canvas`, `--bg-surface`, `--bg-surface-raised`, `--bg-hover`, `--bg-active`, `--bg-selected`, `--bg-disabled`, `--bg-mask` | +| Surfaces | `--bg-canvas`, `--bg-surface`, `--bg-surface-raised`, `--bg-surface-overlay`, `--bg-hover`, `--bg-active`, `--bg-selected`, `--bg-disabled`, `--bg-mask` | +| Placeholders | `--bg-placeholder`, `--bg-placeholder-highlight` (see § Loading placeholders) | | Text | `--text-default`, `--text-muted`, `--text-disabled` | | Borders | `--border-default`, `--border-muted`, `--border-strong`, `--border-selected` | | Feedback | `--success`, `--success-border`, `--success-contrast`, `--warning`, `--warning-border`, `--warning-contrast`, `--danger`, `--danger-border`, `--danger-contrast`, `--info`, `--info-border`, `--info-contrast` | | Code syntax | `--code-sintax-identifier`, `--code-sintax-line-number`, `--code-sintax-keyword`, `--code-sintax-string`, `--code-sintax-function`, `--code-sintax-type`, `--code-sintax-punctuation` | +### Loading placeholders + +A loading placeholder (skeleton, image stand-in, lazy block) does **not** use a surface token. Surface tokens +are opaque and tuned for one background, so a placeholder built from one is only legible on the surface it was +picked against — `--bg-surface-overlay` (`#FAFAFA`) on a `--bg-surface` card (`#FFFFFF`) is a contrast ratio of +**1.02**, effectively invisible. Use the placeholder pair instead: + +| Token | light | dark | Role | +| ---------------------------- | ---------------------- | ---------------------- | --------------------------------------------- | +| `--bg-placeholder` | `#00000014` (8% black) | `#FFFFFF1A` (10% white) | The fill that reserves the content's space. | +| `--bg-placeholder-highlight` | `#FFFFFF99` (60% white) | `#FFFFFF1A` (10% white) | The sweep that passes over the fill while loading. | + +Both are **translucent by design** — that is what makes one value per mode correct everywhere. The fill +composites over whatever sits behind it, so the same token holds its contrast on `--bg-canvas`, `--bg-surface`, +`--bg-surface-raised` and `--bg-surface-overlay` alike (ratio `1.19`–`1.32` across all eight combinations) +instead of being tuned for a single card color. + +The highlight is a white alpha in **both** modes: it *lightens* the fill rather than replacing it. Keep the fill +as `background-color` and put the sweep in a `background-image` gradient whose other stops are `transparent`, so +the animated and static states share the same base tone: + +```html +class="bg-[var(--bg-placeholder)] + data-[animated]:motion-safe:bg-[linear-gradient(90deg,transparent_0%,transparent_35%,var(--bg-placeholder-highlight)_50%,transparent_65%,transparent_100%)] + data-[animated]:motion-safe:bg-[length:200%_100%] + data-[animated]:motion-safe:animate-[var(--animate-shimmer)] + motion-reduce:animate-none" +``` + +Reference: `skeleton.vue`. The sweep pairs with `--animate-shimmer` (see § Motion primitives) and is gated behind +`motion-safe:` with a `motion-reduce:animate-none` fallback. + ### Do not - Hardcode hex/rgb/hsl in component class strings. - Use Tailwind palette names (`bg-gray-500`, `text-violet-600`) in `components/webkit/`. - Use external/legacy color utilities (`text-color`, `surface-*`) in new webkit components — map to `var(--*)` tokens instead. +- Build a loading placeholder from an opaque surface token (`--bg-surface-overlay`, `--bg-surface-raised`) — it only reads on the one surface it was picked against. Use `--bg-placeholder` / `--bg-placeholder-highlight`. Reference: `button.vue` kind variants (`bg-[var(--secondary)]`, `text-[var(--secondary-contrast)]`, etc.). @@ -293,17 +327,27 @@ Reference: `button.vue` kind variants (`bg-[var(--secondary)]`, `text-[var(--sec ## Animations -Use **only** the named utilities below; they ship with `motion-safe` / `motion-reduce` variants enabled by the theme plugin. Defined in `packages/theme/src/tokens/semantic/animations.js`. - -| Utility | Behavior | Duration · Easing | -| ------------------------- | ------------------------- | ----------------------------------------------------------------------------- | -| `animate-fade-in` | opacity 0 → 1 | 220ms · `ease-in-out` | -| `animate-fade-out` | opacity 1 → 0 | 220ms · `ease-in-out` | -| `animate-slide-down` | height 0 → auto | 220ms · `ease-in-out` | -| `animate-popup-scale-in` | scale 0.95 → 1 + fade in | `moderate-01` · `productive-entrance` (origin: `var(--popup-origin, center)`) | -| `animate-popup-scale-out` | scale 1 → 0.95 + fade out | `fast-02` · `productive-exit` | -| `animate-blink` | opacity 1/0 cycle | 1s · `step-end` infinite | -| `animate-highlight-fade` | row-flash highlight | `ease-in forwards` | +Use **only** the named utilities below; they ship with `motion-safe` / `motion-reduce` variants enabled by the theme plugin. The `--animate-*` presets are defined in `packages/theme/src/tokens/primitives/animations/animate.js` (each with a one-line `useWhen` note) and their `@keyframes` in the sibling `keyframes.js`; `build-tokens.mjs` emits both into `dist/v4/globals.css`. + +| Utility | Behavior | Duration · Easing | +| ------------------------------------ | -------------------------------------------- | ----------------------------------------------------------------------------- | +| `animate-fade-in` | opacity 0 → 1 | 220ms · `ease-in-out` | +| `animate-fade-out` | opacity 1 → 0 | 220ms · `ease-in-out` | +| `animate-slide-down` | height 0 → auto | 220ms · `ease-in-out` | +| `animate-popup-scale-in` | scale 0.9 → 1 + fade in | `moderate-01` · `productive-entrance` (origin: `var(--popup-origin, center)`) | +| `animate-popup-scale-out` | scale 1 → 0.9 + fade out | `fast-02` · `productive-exit` | +| `animate-slide-in-left` | translateX -100% → 0 | `moderate-02` · `productive-entrance` | +| `animate-slide-out-left` | translateX 0 → -100% | `moderate-01` · `productive-exit` | +| `animate-slide-in-right` | translateX 100% → 0 | `moderate-02` · `productive-entrance` | +| `animate-slide-out-right` | translateX 0 → 100% | `moderate-01` · `productive-exit` | +| `animate-shimmer` | `background-position` 200% → -200% sweep | 1.6s · `linear` infinite | +| `animate-blink` | opacity 1/0 cycle | 1s · `step-end` infinite | +| `animate-highlight-fade` | row-flash highlight | `ease-in forwards` | +| `animate-progress-indeterminate` | indeterminate bar, primary sweep | `slow-04` · `productive-entrance` infinite | +| `animate-progress-indeterminate-short` | indeterminate bar, secondary short sweep | `slow-04` · `expressive-entrance` (delay `slow-03`) infinite | +| `animate-spin` · `animate-ping` · `animate-pulse` · `animate-bounce` | Tailwind's stock loading/attention loops, re-registered in `animate.js` | see `animate.js` | + +`animate-shimmer` drives a gradient sweep and expects a `background-image` gradient plus `bg-[length:200%_100%]` — see § Loading placeholders for the canonical class string. ### Easing primitives — `primitives/animations/ease.js` diff --git a/.specs/skeleton.md b/.specs/skeleton.md index 3cd5c74ca..2b153f778 100644 --- a/.specs/skeleton.md +++ b/.specs/skeleton.md @@ -7,9 +7,9 @@ spec_version: 1 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=479-881 node_id: 479:881 -checksum: 5ebe624103f06c4dcdc3fac32789b74cc7fd48142bd277515151e8c6d44e5423 +checksum: e62845a1427b073406d90b13110cc5d38a20b9025b7a8cd2d043b20db6c2cd04 created: 2026-06-23 -last_updated: 2026-06-26 +last_updated: 2026-07-28 --- # Skeleton — Component Spec @@ -58,20 +58,21 @@ import Skeleton from '@aziontech/webkit/skeleton' | Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback | |---|---|---|---| -| while loading (`animated`) | `animate-shimmer` (linear gradient sweep over the fill) | `--animate-shimmer` (`animate.js`) + `@keyframes shimmer` (`semantic/animations.js`) | gated behind `motion-safe:` (no sweep) + `motion-reduce:animate-none` (static) | +| while loading (`animated`) | `animate-shimmer` (linear gradient sweep over the fill) | `--animate-shimmer` (`primitives/animations/animate.js`) + `@keyframes shimmer` (`primitives/animations/keyframes.js`) | gated behind `motion-safe:` (no sweep) + `motion-reduce:animate-none` (static) | ## Tokens | Region | Token (DESIGN.md) | |---|---| +| fill | `var(--bg-placeholder)` | +| shimmer sweep | `var(--bg-placeholder-highlight)` | | shape radius (`shape`) | `var(--shape-elements)` | +`--bg-placeholder` and `--bg-placeholder-highlight` are **translucent** (alpha) by design: the fill composites over whatever surface the placeholder sits on (`--bg-canvas`, `--bg-surface`, `--bg-surface-raised`, `--bg-surface-overlay`), so a single value per mode keeps the contrast correct everywhere. The sweep is a white alpha in both modes — it lightens the fill rather than replacing it, so the animated and static states share the same base tone. + ## Theme gaps -| Figma variable | Temporary primitive | Follow-up | -|---|---|---| -| `--bg-surface-overlay` (skeleton fill, #4D4D4D dark / #FAFAFA light) | `bg-[var(--bg-surface-overlay)]` (real token, not yet in DESIGN.md) | `TODO: document --bg-surface-overlay in DESIGN.md` | -| shimmer animation | `animate-shimmer` (`--animate-shimmer` preset in `animate.js` + `@keyframes shimmer` in `semantic/animations.js`) | `TODO: document --animate-shimmer in DESIGN.md § Animations` | +| _none_ | — | Both prior gaps are closed: `--bg-placeholder` / `--bg-placeholder-highlight` are documented in `.claude/docs/DESIGN.md` § Colors → Loading placeholders, and `--animate-shimmer` in § Animations. | ## Accessibility (WCAG 2.1 AA) diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-mobile.png index 057d8879a..d560726ed 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-mobile.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-tablet.png index b8a59c51c..1aa7252c9 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-tablet.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--dark-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-desktop.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-desktop.png index 52a415d7b..0a07d0824 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-desktop.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-desktop.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-mobile.png index 83b015552..42023824f 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-mobile.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-tablet.png index 8908e7843..0ac1f6314 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-tablet.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default--light-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default.png index 9c091a775..f9593c0cb 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--default.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--dark-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--dark-mobile.png new file mode 100644 index 000000000..557d51675 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--dark-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--dark-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--dark-tablet.png new file mode 100644 index 000000000..933760064 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--dark-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-desktop.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-desktop.png new file mode 100644 index 000000000..383c00019 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-desktop.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-mobile.png new file mode 100644 index 000000000..e4995b559 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-tablet.png new file mode 100644 index 000000000..85f92bcd1 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces--light-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces.png new file mode 100644 index 000000000..c44969e42 Binary files /dev/null and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--on-surfaces.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-mobile.png index 87e21545a..f3b838f46 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-mobile.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-tablet.png index 12c091808..4f2bf4f32 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-tablet.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--dark-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-desktop.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-desktop.png index 2c2d925a2..6e84b322f 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-desktop.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-desktop.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-mobile.png index 45341086c..4827dafad 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-mobile.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-tablet.png index 2cea2fddf..671d9a352 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-tablet.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static--light-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static.png index 3bf9cee32..8dfc5ee8f 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--static.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-mobile.png index fa871d510..eda18c952 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-mobile.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-tablet.png index bdfe22cc9..ed2dcea51 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-tablet.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--dark-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-desktop.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-desktop.png index 5a5f90f45..10040fc75 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-desktop.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-desktop.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-mobile.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-mobile.png index 983b44676..72ef51360 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-mobile.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-mobile.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-tablet.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-tablet.png index c9936412c..4fe9a77c7 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-tablet.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types--light-tablet.png differ diff --git a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types.png b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types.png index e36fd6771..3364f014f 100644 Binary files a/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types.png and b/apps/storybook/.storybook/test-visual/__image_snapshots__/linux/components-feedback-skeleton--types.png differ diff --git a/apps/storybook/src/foundations/utils/theme-tokens.js b/apps/storybook/src/foundations/utils/theme-tokens.js index 41393b057..31b6bae2c 100644 --- a/apps/storybook/src/foundations/utils/theme-tokens.js +++ b/apps/storybook/src/foundations/utils/theme-tokens.js @@ -99,6 +99,9 @@ const DESCRIPTIONS = { '--bg-surface': 'Primary element background (cards, panels).', '--bg-surface-raised': 'Raised element background — one step above the surface.', '--bg-surface-overlay': 'Floating surface for menus, popovers and dropdowns.', + '--bg-placeholder': + 'Translucent fill for loading placeholders (skeletons, image stand-ins) — composites over any surface.', + '--bg-placeholder-highlight': 'Sweep highlight passing over a loading placeholder.', '--bg-hover': 'Hover overlay for interactive surfaces.', '--bg-active': 'Active / pressed overlay for interactive surfaces.', '--bg-selected': 'Background for a selected element.', diff --git a/apps/storybook/src/stories/components/feedback/skeleton/Skeleton.stories.js b/apps/storybook/src/stories/components/feedback/skeleton/Skeleton.stories.js index b2c6caab8..86a730dcd 100644 --- a/apps/storybook/src/stories/components/feedback/skeleton/Skeleton.stories.js +++ b/apps/storybook/src/stories/components/feedback/skeleton/Skeleton.stories.js @@ -120,6 +120,47 @@ export const Types = { } } +const SURFACES_TEMPLATE = `
+
+ --bg-canvas + + +
+
+ --bg-surface + + +
+
+ --bg-surface-raised + + +
+
+ --bg-surface-overlay + + +
+
` + +/** @type {import('@storybook/vue3').StoryObj} */ +export const OnSurfaces = { + render: () => ({ + components: { Skeleton }, + template: SURFACES_TEMPLATE + }), + parameters: { + docs: { + controls: { disable: true }, + description: { + story: + 'The same placeholder on all four surface layers. The fill is `var(--bg-placeholder)`, a translucent role rather than an opaque surface, so it composites over whatever sits behind it and holds its contrast on every layer — in both themes. Built from a surface token instead, a placeholder is only legible on the one surface it was picked against.' + }, + source: { code: toSfc(IMPORT, SURFACES_TEMPLATE) } + } + } +} + const STATIC_MARKUP = '' /** @type {import('@storybook/vue3').StoryObj} */ diff --git a/packages/theme/dist/v4/globals.css b/packages/theme/dist/v4/globals.css index 559c94e9a..8d93fa5cf 100644 --- a/packages/theme/dist/v4/globals.css +++ b/packages/theme/dist/v4/globals.css @@ -845,6 +845,8 @@ --bg-surface-raised: #FFFFFF; --bg-mask: #00000005; --bg-surface-overlay: #FAFAFA; + --bg-placeholder: #00000014; + --bg-placeholder-highlight: #FFFFFF99; --bg-hover: #00000014; --bg-active: #00000005; --bg-backdrop: #000000CC; @@ -913,6 +915,8 @@ --bg-surface-raised: #141414; --bg-mask: #FFFFFF0D; --bg-surface-overlay: #4D4D4D; + --bg-placeholder: #FFFFFF1A; + --bg-placeholder-highlight: #FFFFFF1A; --bg-hover: #00000033; --bg-active: #00000014; --bg-backdrop: #000000CC; @@ -1323,6 +1327,8 @@ @utility bg-surface-raised { background-color: var(--bg-surface-raised); } @utility bg-mask { background-color: var(--bg-mask); } @utility bg-surface-overlay { background-color: var(--bg-surface-overlay); } +@utility bg-placeholder { background-color: var(--bg-placeholder); } +@utility bg-placeholder-highlight { background-color: var(--bg-placeholder-highlight); } @utility bg-hover { background-color: var(--bg-hover); } @utility bg-active { background-color: var(--bg-active); } @utility bg-backdrop { background-color: var(--bg-backdrop); } diff --git a/packages/theme/dist/v4/globals.scss b/packages/theme/dist/v4/globals.scss index 559c94e9a..8d93fa5cf 100644 --- a/packages/theme/dist/v4/globals.scss +++ b/packages/theme/dist/v4/globals.scss @@ -845,6 +845,8 @@ --bg-surface-raised: #FFFFFF; --bg-mask: #00000005; --bg-surface-overlay: #FAFAFA; + --bg-placeholder: #00000014; + --bg-placeholder-highlight: #FFFFFF99; --bg-hover: #00000014; --bg-active: #00000005; --bg-backdrop: #000000CC; @@ -913,6 +915,8 @@ --bg-surface-raised: #141414; --bg-mask: #FFFFFF0D; --bg-surface-overlay: #4D4D4D; + --bg-placeholder: #FFFFFF1A; + --bg-placeholder-highlight: #FFFFFF1A; --bg-hover: #00000033; --bg-active: #00000014; --bg-backdrop: #000000CC; @@ -1323,6 +1327,8 @@ @utility bg-surface-raised { background-color: var(--bg-surface-raised); } @utility bg-mask { background-color: var(--bg-mask); } @utility bg-surface-overlay { background-color: var(--bg-surface-overlay); } +@utility bg-placeholder { background-color: var(--bg-placeholder); } +@utility bg-placeholder-highlight { background-color: var(--bg-placeholder-highlight); } @utility bg-hover { background-color: var(--bg-hover); } @utility bg-active { background-color: var(--bg-active); } @utility bg-backdrop { background-color: var(--bg-backdrop); } diff --git a/packages/theme/src/tokens/theme/background.js b/packages/theme/src/tokens/theme/background.js index 0b62e7816..1d186e7f8 100644 --- a/packages/theme/src/tokens/theme/background.js +++ b/packages/theme/src/tokens/theme/background.js @@ -7,6 +7,8 @@ export const background = { 'bg-surface-raised': tokenRef('theme.surfaces.surface-0'), 'bg-mask': tokenRef('primitives.alpha.black.50'), 'bg-surface-overlay': tokenRef('theme.surfaces.surface-50'), + 'bg-placeholder': tokenRef('primitives.alpha.black.100'), + 'bg-placeholder-highlight': tokenRef('primitives.alpha.white.600'), 'bg-hover': tokenRef('primitives.alpha.black.100'), 'bg-active': tokenRef('primitives.alpha.black.50'), 'bg-backdrop': tokenRef('primitives.alpha.black.800'), @@ -20,6 +22,8 @@ export const background = { 'bg-surface-raised': tokenRef('theme.surfaces.surface-900'), 'bg-mask': tokenRef('primitives.alpha.white.50'), 'bg-surface-overlay': tokenRef('theme.surfaces.surface-700'), + 'bg-placeholder': tokenRef('primitives.alpha.white.100'), + 'bg-placeholder-highlight': tokenRef('primitives.alpha.white.100'), 'bg-hover': tokenRef('primitives.alpha.black.200'), 'bg-active': tokenRef('primitives.alpha.black.100'), 'bg-backdrop': tokenRef('primitives.alpha.black.800'), diff --git a/packages/webkit/catalog.json b/packages/webkit/catalog.json index b5ae79123..fd040f78d 100644 --- a/packages/webkit/catalog.json +++ b/packages/webkit/catalog.json @@ -68,6 +68,8 @@ "--bg-disabled", "--bg-hover", "--bg-mask", + "--bg-placeholder", + "--bg-placeholder-highlight", "--bg-selected", "--bg-surface", "--bg-surface-overlay", @@ -746,6 +748,8 @@ "--bg-disabled", "--bg-hover", "--bg-mask", + "--bg-placeholder", + "--bg-placeholder-highlight", "--bg-selected", "--bg-surface", "--bg-surface-overlay", diff --git a/packages/webkit/cli-templates/claude/skills/webkit-theming-dark-mode/SKILL.md b/packages/webkit/cli-templates/claude/skills/webkit-theming-dark-mode/SKILL.md index 299b0a4cc..dbea3c9b0 100644 --- a/packages/webkit/cli-templates/claude/skills/webkit-theming-dark-mode/SKILL.md +++ b/packages/webkit/cli-templates/claude/skills/webkit-theming-dark-mode/SKILL.md @@ -95,6 +95,25 @@ names from the theme package; never invent a `surface-3`. ``` +## Loading placeholders use a translucent role, not a surface + +A surface token is opaque and tuned for one background, so a skeleton built from one is only legible on +the surface it was picked against — and it inverts badly across themes. `--bg-placeholder` (the fill) +and `--bg-placeholder-highlight` (the sweep) are **translucent**: they composite over whatever layer +they sit on, so the same token holds its contrast on canvas, surface, raised and overlay alike, in both +themes. Keep the fill as `background-color` and put the sweep in a gradient whose other stops are +`transparent`, so the animated and static states share one base tone. + +```vue + +
+ +
+``` + +Prefer the design system's `Skeleton` component over hand-rolling this — it already pairs the tokens +with the shimmer animation and its reduced-motion fallback. + ## Don't fight the tokens with `dark:` overrides If a role has a token, the token already handles both themes — adding `dark:bg-…` on top is redundant diff --git a/packages/webkit/src/components/feedback/skeleton/skeleton.test.ts b/packages/webkit/src/components/feedback/skeleton/skeleton.test.ts index ec1c1dc4b..b76a7ca0f 100644 --- a/packages/webkit/src/components/feedback/skeleton/skeleton.test.ts +++ b/packages/webkit/src/components/feedback/skeleton/skeleton.test.ts @@ -6,7 +6,7 @@ import * as stories from '../../../../../../apps/storybook/src/stories/component import { expectNoA11yViolations } from '../../../test/axe' import Skeleton from './skeleton.vue' -const { Default, Types, Static } = composeStories(stories) +const { Default, Types, Static, OnSurfaces } = composeStories(stories) describe('Skeleton', () => { it('renders with the default data-testid and presentational a11y semantics', () => { @@ -111,4 +111,15 @@ describe('Skeleton', () => { expect(getByTestId('feedback-skeleton').hasAttribute('data-animated')).toBe(false) }) + + // The fill is a translucent role, so one value per theme reads correctly on every + // layer. Whether it *reads* is the visual layer's job (this story is snapshotted in + // both themes); here we only prove the fixture renders on all four surfaces and + // stays a11y-clean, so a broken story fails a test rather than a screenshot diff. + it('renders the composed OnSurfaces story on every surface layer', async () => { + const { getAllByTestId, container } = render(OnSurfaces()) + + expect(getAllByTestId('feedback-skeleton')).toHaveLength(8) + await expectNoA11yViolations(container) + }) }) diff --git a/packages/webkit/src/components/feedback/skeleton/skeleton.vue b/packages/webkit/src/components/feedback/skeleton/skeleton.vue index b0c6ad2f9..86275298f 100644 --- a/packages/webkit/src/components/feedback/skeleton/skeleton.vue +++ b/packages/webkit/src/components/feedback/skeleton/skeleton.vue @@ -40,6 +40,6 @@ :data-kind="kind" :data-animated="animated || null" :style="{ width, height }" - class="block bg-[var(--bg-surface-overlay)] data-[kind=shape]:rounded-[var(--shape-elements)] data-[kind=circle]:rounded-full data-[animated]:motion-safe:bg-[linear-gradient(90deg,var(--bg-surface-raised)_0%,var(--bg-surface)_35%,var(--bg-surface-raised)_50%,var(--bg-surface-raised)_65%,var(--bg-surface-raised)_100%)] data-[animated]:motion-safe:bg-[length:200%_100%] data-[animated]:motion-safe:animate-[var(--animate-shimmer)] motion-reduce:animate-none" + class="block bg-[var(--bg-placeholder)] data-[kind=shape]:rounded-[var(--shape-elements)] data-[kind=circle]:rounded-full data-[animated]:motion-safe:bg-[linear-gradient(90deg,transparent_0%,transparent_35%,var(--bg-placeholder-highlight)_50%,transparent_65%,transparent_100%)] data-[animated]:motion-safe:bg-[length:200%_100%] data-[animated]:motion-safe:animate-[var(--animate-shimmer)] motion-reduce:animate-none" />