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 + +