diff --git a/packages/theme/dist/v4/globals.css b/packages/theme/dist/v4/globals.css index e3a436b00..2b5d54ca2 100644 --- a/packages/theme/dist/v4/globals.css +++ b/packages/theme/dist/v4/globals.css @@ -409,6 +409,8 @@ --animate-fade-out: fadeOut 220ms ease-in-out; --animate-slide-down: slideDown 220ms ease-in-out; --animate-highlight-fade: highlight 1100ms ease-in forwards; + --animate-page-enter: pageEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1); + --animate-content-enter: contentEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1) var(--content-enter-delay, 0s) backwards; --animate-popup-scale-in: popupScaleIn 150ms cubic-bezier(0.39, 0.57, 0.56, 1); --animate-popup-scale-out: popupScaleOut 110ms cubic-bezier(0.55, 0.09, 0.68, 0.53); --animate-slide-in-left: slideInLeft 240ms cubic-bezier(0.39, 0.57, 0.56, 1); @@ -668,6 +670,8 @@ --animate-fade-out: fadeOut 220ms ease-in-out; --animate-slide-down: slideDown 220ms ease-in-out; --animate-highlight-fade: highlight 1100ms ease-in forwards; + --animate-page-enter: pageEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1); + --animate-content-enter: contentEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1) var(--content-enter-delay, 0s) backwards; --animate-popup-scale-in: popupScaleIn 150ms cubic-bezier(0.39, 0.57, 0.56, 1); --animate-popup-scale-out: popupScaleOut 110ms cubic-bezier(0.55, 0.09, 0.68, 0.53); --animate-slide-in-left: slideInLeft 240ms cubic-bezier(0.39, 0.57, 0.56, 1); @@ -1013,6 +1017,16 @@ 100% { background-position: -200% 0; } } +@keyframes pageEnter { + 0% { opacity: 0; translate: calc(var(--page-enter-distance, var(--layout-boundary-inline)) * -1) 0; } + 100% { opacity: 1; translate: 0 0; } +} + +@keyframes contentEnter { + 0% { opacity: 0; translate: 0 var(--spacing-xs); } + 100% { opacity: 1; translate: 0 0; } +} + @keyframes popupScaleIn { 0% { opacity: 0; transform: scale(0.9); } 100% { opacity: 1; transform: scale(1); } diff --git a/packages/theme/dist/v4/globals.scss b/packages/theme/dist/v4/globals.scss index e3a436b00..2b5d54ca2 100644 --- a/packages/theme/dist/v4/globals.scss +++ b/packages/theme/dist/v4/globals.scss @@ -409,6 +409,8 @@ --animate-fade-out: fadeOut 220ms ease-in-out; --animate-slide-down: slideDown 220ms ease-in-out; --animate-highlight-fade: highlight 1100ms ease-in forwards; + --animate-page-enter: pageEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1); + --animate-content-enter: contentEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1) var(--content-enter-delay, 0s) backwards; --animate-popup-scale-in: popupScaleIn 150ms cubic-bezier(0.39, 0.57, 0.56, 1); --animate-popup-scale-out: popupScaleOut 110ms cubic-bezier(0.55, 0.09, 0.68, 0.53); --animate-slide-in-left: slideInLeft 240ms cubic-bezier(0.39, 0.57, 0.56, 1); @@ -668,6 +670,8 @@ --animate-fade-out: fadeOut 220ms ease-in-out; --animate-slide-down: slideDown 220ms ease-in-out; --animate-highlight-fade: highlight 1100ms ease-in forwards; + --animate-page-enter: pageEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1); + --animate-content-enter: contentEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1) var(--content-enter-delay, 0s) backwards; --animate-popup-scale-in: popupScaleIn 150ms cubic-bezier(0.39, 0.57, 0.56, 1); --animate-popup-scale-out: popupScaleOut 110ms cubic-bezier(0.55, 0.09, 0.68, 0.53); --animate-slide-in-left: slideInLeft 240ms cubic-bezier(0.39, 0.57, 0.56, 1); @@ -1013,6 +1017,16 @@ 100% { background-position: -200% 0; } } +@keyframes pageEnter { + 0% { opacity: 0; translate: calc(var(--page-enter-distance, var(--layout-boundary-inline)) * -1) 0; } + 100% { opacity: 1; translate: 0 0; } +} + +@keyframes contentEnter { + 0% { opacity: 0; translate: 0 var(--spacing-xs); } + 100% { opacity: 1; translate: 0 0; } +} + @keyframes popupScaleIn { 0% { opacity: 0; transform: scale(0.9); } 100% { opacity: 1; transform: scale(1); } diff --git a/packages/theme/src/tokens/primitives/animations/animate.js b/packages/theme/src/tokens/primitives/animations/animate.js index 26983c6f0..06282252c 100644 --- a/packages/theme/src/tokens/primitives/animations/animate.js +++ b/packages/theme/src/tokens/primitives/animations/animate.js @@ -27,6 +27,8 @@ export const animate = { 'fade-out': 'fadeOut 220ms ease-in-out', 'slide-down': 'slideDown 220ms ease-in-out', 'highlight-fade': `highlight ${duration['slow-03']} ease-in forwards`, + 'page-enter': `pageEnter ${duration['moderate-02']} ${curve['productive-entrance']}`, + 'content-enter': `contentEnter ${duration['moderate-02']} ${curve['productive-entrance']} var(--content-enter-delay, 0s) backwards`, 'popup-scale-in': `popupScaleIn ${duration['moderate-01']} ${curve['productive-entrance']}`, 'popup-scale-out': `popupScaleOut ${duration['fast-02']} ${curve['productive-exit']}`, 'slide-in-left': `slideInLeft ${duration['moderate-02']} ${curve['productive-entrance']}`, @@ -41,6 +43,10 @@ export const animate = { } export const useWhen = { + 'page-enter': + 'A page arriving on a route change — on the CONTENT ZONE only, never the shell. The chrome is the same before and after, and sliding it announces a reload that did not happen. Key it on the route path so a component serving several paths still replays it. Nothing inside the page may animate on mount at the same time: the two run in lockstep and read as one element travelling on a diagonal.', + 'content-enter': + 'Content settling INSIDE a page that is already on screen — a loading window resolving, a filtered list swapping, a step changing. Never on first paint of a page whose own entrance is already running (use it after that entrance, or after a wire). Stagger a follower with --content-enter-delay.', spin: 'Indeterminate circular spinners (loading icons).', ping: 'One-off attention ring radiating from a small element (notification dot).', pulse: 'Skeleton/placeholder opacity pulse while content loads.', diff --git a/packages/theme/src/tokens/primitives/animations/keyframes.js b/packages/theme/src/tokens/primitives/animations/keyframes.js index 3abf1841e..54681996f 100644 --- a/packages/theme/src/tokens/primitives/animations/keyframes.js +++ b/packages/theme/src/tokens/primitives/animations/keyframes.js @@ -38,6 +38,14 @@ export const keyframes = { '0%': 'background-position: 200% 0', '100%': 'background-position: -200% 0' }, + pageEnter: { + '0%': 'opacity: 0; translate: calc(var(--page-enter-distance, var(--layout-boundary-inline)) * -1) 0', + '100%': 'opacity: 1; translate: 0 0' + }, + contentEnter: { + '0%': 'opacity: 0; translate: 0 var(--spacing-xs)', + '100%': 'opacity: 1; translate: 0 0' + }, popupScaleIn: { '0%': 'opacity: 0; transform: scale(0.9)', '100%': 'opacity: 1; transform: scale(1)' diff --git a/packages/webkit/catalog.json b/packages/webkit/catalog.json index 42426061e..36d5bb15f 100644 --- a/packages/webkit/catalog.json +++ b/packages/webkit/catalog.json @@ -44,10 +44,12 @@ "--accent-selected", "--animate-blink", "--animate-bounce", + "--animate-content-enter", "--animate-fade-in", "--animate-fade-out", "--animate-flow-dash", "--animate-highlight-fade", + "--animate-page-enter", "--animate-ping", "--animate-popup-scale-in", "--animate-popup-scale-out", @@ -742,10 +744,12 @@ "animate": [ "--animate-blink", "--animate-bounce", + "--animate-content-enter", "--animate-fade-in", "--animate-fade-out", "--animate-flow-dash", "--animate-highlight-fade", + "--animate-page-enter", "--animate-ping", "--animate-popup-scale-in", "--animate-popup-scale-out", @@ -1473,10 +1477,12 @@ "animations": [ "blink", "bounce", + "content-enter", "fade-in", "fade-out", "flow-dash", "highlight-fade", + "page-enter", "ping", "popup-scale-in", "popup-scale-out", @@ -1500,6 +1506,10 @@ "value": "bounce 1s infinite", "useWhen": "Playful attention bounce (scroll-down hints); use sparingly." }, + "content-enter": { + "value": "contentEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1) var(--content-enter-delay, 0s) backwards", + "useWhen": "Content settling INSIDE a page that is already on screen — a loading window resolving, a filtered list swapping, a step changing. Never on first paint of a page whose own entrance is already running (use it after that entrance, or after a wire). Stagger a follower with --content-enter-delay." + }, "fade-in": { "value": "fadeIn 220ms ease-in-out", "useWhen": "Content or backdrop appearing in place (no directional origin)." @@ -1516,6 +1526,10 @@ "value": "highlight 1100ms ease-in forwards", "useWhen": "Row/item briefly highlighted after an update (recently changed)." }, + "page-enter": { + "value": "pageEnter 240ms cubic-bezier(0.39, 0.57, 0.56, 1)", + "useWhen": "A page arriving on a route change — on the CONTENT ZONE only, never the shell. The chrome is the same before and after, and sliding it announces a reload that did not happen. Key it on the route path so a component serving several paths still replays it. Nothing inside the page may animate on mount at the same time: the two run in lockstep and read as one element travelling on a diagonal." + }, "ping": { "value": "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite", "useWhen": "One-off attention ring radiating from a small element (notification dot)." diff --git a/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md b/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md index 85e6d5136..dcc02942b 100644 --- a/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md +++ b/packages/webkit/cli-templates/claude/skills/webkit-motion-polish/SKILL.md @@ -2,7 +2,7 @@ name: webkit-motion-polish description: Make motion smooth using only @aziontech/theme animate tokens — animate-* utilities, duration-*/ease-*/curve tokens, compositor-props-only, ≤150ms interaction feedback, and a mandatory motion-reduce escape on every motion class. No external animation library, no inline cubic-bezier, no hardcoded ms. status: active -last_updated: 2026-08-11 +last_updated: 2026-08-12 scope: general enforced_by: [webkit-motion, webkit-accessibility, ui-verify] --- @@ -229,6 +229,72 @@ onMounted(() => { the card swaps. Give each step its own copy and every step change replays the 400ms slide, so the user reads three page loads where they only changed step. +### The two arrivals ship in the theme — do not hand-roll them + +`@aziontech/theme` carries both entrances an app shell needs, as ordinary utilities. Use them; a +per-app copy is a copy that drifts, and the reasoning below is already baked into these. + +| Utility | What it is for | Where it goes | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `animate-page-enter` | A page arriving on a route change. Travels in from the LEFT — navigation lives on that edge, so a page arriving from it reads as coming FROM the row that was clicked; a plain fade has no origin and a right-side arrival reads as going back. | The **content zone only**, keyed on the route path. Never the shell. | +| `animate-content-enter` | Content settling inside a page that is already on screen — a load resolving, a list swapping, a step changing. Rises a hair rather than travelling: the page is in place and only its contents changed. | The block that changed. | + +```vue + +
+ +
+ + + +
+``` + +- **`--content-enter-delay`** is the stagger knob (default `0s`). One `fast-01` between lead and + follow — simultaneous arrival reads as a swap, the offset reads as choreography. +- **`--page-enter-distance`** is the travel (default: one `--layout-boundary-inline`). Retune it per + shell, not per page. +- **Neither fills forwards, on purpose.** A filled entrance leaves a `translate` on the element, + which makes it a containing block for any `position: fixed` descendant — and the page-enter box is + usually the scroll container, so its sticky children would start measuring against it. +- **`motion-reduce:animate-none` on both**, always. + +### A page arrives once + +The route transition **is** the page's entrance. Anything inside that page which also animates **on +mount** runs concurrently with it — and because both usually take their timing from the same tokens, +they run in lockstep, which does not read as two animations. It reads as one element travelling on a +diagonal. + +The signature, sampled across `requestAnimationFrame` on a real navigation: + +``` + 8ms block: opacity 0.10, rising 7.2px page: opacity 0.10, sliding -21.5px + 91ms block: opacity 0.63, rising 2.96px page: opacity 0.63, sliding -8.88px +``` + +**Identical opacities frame for frame is the tell** — one entrance, played twice, on two axes. Fix it +so the page lands first (`opacity 1`, `translate none`) and only then does anything inside it move. + +A content-settle entrance is earned only when the content changes inside a page that is **already +standing**. Three shapes this goes wrong in: + +| Shape | What you see | Fix | +| -------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| A settle class on a block that _is_ the page | The diagonal above | Drop it. The route transition carries the arrival; animate only on a swap, or after a load resolves | +| Two variants of one screen, each rendering its own shell | Chrome blinks and the page re-slides on a switch that navigated nowhere | Hoist the shell to the parent that swaps them, so only the content zone changes | +| A screen whose content is genuinely read on arrival | Content pops in when the read lands | A wire for the read, then the settle after it — two beats, never overlapping | + +- **A wire is per shape, not per route.** If one URL resolves to two different layouts, each needs a + wire of **its own** shape. A wire that resolves into a different layout is the jump it exists to + prevent. Verify by measuring the block's top and height in the wire and after it settles — a couple + of px is a settle, tens of px is a jump. +- **Measure, never eyeball.** A snap and a 240ms ease are indistinguishable by eye in review. Sample + the animated property across `requestAnimationFrame` and assert there are interpolated frames + between the two ends — and that the page's own frames are finished before the content's begin. + ### Route and step transitions ```vue @@ -265,22 +331,24 @@ onMounted(() => { Every entry here compiles, lints, and passes type-check. They fail **silently** — that is what makes them expensive. When motion "doesn't work" and nothing is red, start here. -| Symptom | Cause | Fix | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| Element lands in the right place, no motion at all | `transition-[transform]` beside `translate-x-*` / `scale-*` — v4 compiles those to `translate` / `scale` | Name the real properties: `transition-[translate,scale,opacity]` | -| Entrance snaps to its final state | A single `requestAnimationFrame` — the from-state was never painted | Two nested `requestAnimationFrame`s | -| Routed step is blank when navigated to, correct on reload | `` as the direct child of `` — the leave completes and the entering branch is dropped | Wrap it in a stable keyed **element**; keying the component does nothing | -| `` around a component does nothing | The component's root is a fragment | Give it one root element | -| Growing animates, shrinking snaps | Height pinned _before_ `scrollHeight` was read | Measure both heights while still `auto` | -| Box stays stuck at a pinned height | `transitionend` never arrived (interrupted, unmounted) | Release on a timeout fallback too | -| Height transition jitters / never settles | `ResizeObserver` is watching the element being sized | Observe an inner element that is never given a height | -| Box dips mid-swap | `out-in` inside a height-animated container | Cross-fade with the leaving block `absolute` | -| Focus ring is clipped on flush-edge controls | Permanent `overflow-hidden` on the animated box | Clip only while moving, via `data-[resizing]:` | -| Consumer's `transition-*` class is ignored | An inline `style="transition: …"` on the same element beats every class | Express it as a utility, or accept the component owns that transition | -| Enter-from opacity has no effect | A base `opacity-*` on the same element is emitted after `opacity-0` | Recede with token colours (border/fill), and leave `opacity` to the transition | -| Reordering a list kills the item's own animation | Re-inserting a node discards any transition pending on it or inside it | A list either reorders or its items animate in place — not both on one interaction | -| A badge/dot is cut off, or the control jumps when it appears | The host clips to its own shape (`overflow-hidden`), and an in-flow marker widens the box | Overlay it on a `relative` wrapper _outside_ the control, `pointer-events-none` | -| Element fades out but never leaves | A DS component's own remove animation completed and _then_ emitted — correct for a thing going away, wrong for a thing that stays | Own the control locally when the element must survive | +| Symptom | Cause | Fix | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Element lands in the right place, no motion at all | `transition-[transform]` beside `translate-x-*` / `scale-*` — v4 compiles those to `translate` / `scale` | Name the real properties: `transition-[translate,scale,opacity]` | +| Entrance snaps to its final state | A single `requestAnimationFrame` — the from-state was never painted | Two nested `requestAnimationFrame`s | +| Routed step is blank when navigated to, correct on reload | `` as the direct child of `` — the leave completes and the entering branch is dropped | Wrap it in a stable keyed **element**; keying the component does nothing | +| `` around a component does nothing | The component's root is a fragment | Give it one root element | +| Growing animates, shrinking snaps | Height pinned _before_ `scrollHeight` was read | Measure both heights while still `auto` | +| Box stays stuck at a pinned height | `transitionend` never arrived (interrupted, unmounted) | Release on a timeout fallback too | +| Height transition jitters / never settles | `ResizeObserver` is watching the element being sized | Observe an inner element that is never given a height | +| Box dips mid-swap | `out-in` inside a height-animated container | Cross-fade with the leaving block `absolute` | +| Focus ring is clipped on flush-edge controls | Permanent `overflow-hidden` on the animated box | Clip only while moving, via `data-[resizing]:` | +| Consumer's `transition-*` class is ignored | An inline `style="transition: …"` on the same element beats every class | Express it as a utility, or accept the component owns that transition | +| Enter-from opacity has no effect | A base `opacity-*` on the same element is emitted after `opacity-0` | Recede with token colours (border/fill), and leave `opacity` to the transition | +| Reordering a list kills the item's own animation | Re-inserting a node discards any transition pending on it or inside it | A list either reorders or its items animate in place — not both on one interaction | +| A badge/dot is cut off, or the control jumps when it appears | The host clips to its own shape (`overflow-hidden`), and an in-flow marker widens the box | Overlay it on a `relative` wrapper _outside_ the control, `pointer-events-none` | +| Content rises while the page is still sliding in | A mount-time entrance inside a page whose route transition is already running — same tokens, so they lockstep into one diagonal move | Let the route transition own the arrival; animate content only on a swap or after a load | +| Chrome blinks and the page re-slides, but nothing navigated | Two variants of one screen each render their own shell, so swapping variants unmounts the sidebar/header and replays the route transition | Hoist the shell to the parent that swaps the variants | +| Element fades out but never leaves | A DS component's own remove animation completed and _then_ emitted — correct for a thing going away, wrong for a thing that stays | Own the control locally when the element must survive | ### Timing only from tokens