feat(ui): motion tokens, keyframes and reduced-motion switch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eed59d4ef2
commit
17b6ae3828
3 files changed
+136
-4
No files matched your search
@@ -39,7 +39,7 @@ export const learnerCss = String.raw`
|
||||
/* ---------- course shell ---------- */
|
||||
.stc-main:has(> .lr-shell) { padding: 0; }
|
||||
.lr-shell { display: flex; align-items: flex-start; }
|
||||
.lr-side { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
|
||||
.lr-side { view-transition-name: lr-side; position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
|
||||
.lr-scrim { display: none; }
|
||||
.lr-main { flex: 1; min-width: 0; padding: 44px 40px 0; }
|
||||
.lr-side-head { display: flex; gap: 12px; padding: 18px 16px 16px; border-bottom: 1px solid var(--line); }
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
--sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--serif: "Literata", Georgia, "Times New Roman", serif;
|
||||
--mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
--top: 52px; --sidew: 296px; --fast: 140ms;
|
||||
--top: 52px; --sidew: 296px;
|
||||
/* motion (v1.1): light, ease-out, opacity and short rises only; prefers-reduced-motion turns it all off */
|
||||
--t-exit: 120ms; --t-fast: 140ms; --t-enter: 180ms; --t-fold: 200ms; --t-bar: 220ms; --fast: 140ms;
|
||||
--ease-out: cubic-bezier(.2, .7, .3, 1); --rise: 5px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
@@ -23,7 +26,7 @@ code, pre { font-family: var(--mono); }
|
||||
.work { font-family: var(--sans); font-size: 15.5px; line-height: 1.6; color: var(--text); max-width: 760px; }
|
||||
|
||||
/* shell */
|
||||
.stc-top { position: sticky; top: 0; z-index: 30; height: var(--top); display: flex; align-items: center; gap: 14px; padding: 0 16px; background: var(--ground); border-bottom: 1px solid var(--line); }
|
||||
.stc-top { view-transition-name: stc-top; position: sticky; top: 0; z-index: 30; height: var(--top); display: flex; align-items: center; gap: 14px; padding: 0 16px; background: var(--ground); border-bottom: 1px solid var(--line); }
|
||||
/* quiet pill nav, as the mockup's segmented control: muted text, the current area raised in accent */
|
||||
.stc-nav { display: flex; align-items: center; gap: 2px; }
|
||||
.stc-nav a { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border-radius: 16px; color: var(--muted); text-decoration: none; font-size: 13px; font-weight: 500; transition: background 120ms ease-out, color 120ms ease-out; }
|
||||
@@ -52,7 +55,7 @@ code, pre { font-family: var(--mono); }
|
||||
|
||||
/* progress */
|
||||
.stc-bar { display: block; flex: 1; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
|
||||
.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
|
||||
.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width var(--t-bar) var(--ease-out); }
|
||||
.stc-bar--dim > i { background: var(--accent-dim); }
|
||||
|
||||
/* floating action bar */
|
||||
@@ -65,3 +68,33 @@ code, pre { font-family: var(--mono); }
|
||||
.stc-cover { flex: none; position: relative; width: var(--cw, 44px); aspect-ratio: 3 / 4; border-radius: 6px; background: var(--cbg, var(--raised)); color: var(--cfg, var(--accent)); border: 1px solid color-mix(in srgb, var(--cfg, var(--accent)) 18%, transparent); overflow: hidden; font: 600 calc(var(--cw, 44px) * .34) / 1 var(--sans); letter-spacing: -.03em; display: flex; align-items: flex-end; padding: calc(var(--cw, 44px) * .12); }
|
||||
.stc-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; opacity: 0; transition: opacity 120ms ease-out; }
|
||||
.stc-cover img[data-shown] { opacity: 1; }
|
||||
|
||||
/* ---------- motion ---------- */
|
||||
@keyframes stc-enter { from { opacity: 0; translate: 0 var(--rise); } }
|
||||
@keyframes stc-fade-in { from { opacity: 0; } }
|
||||
@keyframes stc-fade-out { to { opacity: 0; } }
|
||||
@keyframes stc-drop { from { opacity: 0; translate: 0 -4px; } }
|
||||
/* the outgoing slide stays fully visible under the incoming one, then goes */
|
||||
@keyframes stc-hold-out { from { opacity: 1; } 99% { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes stc-slide-next { from { opacity: 0; translate: 4px 0; } }
|
||||
@keyframes stc-slide-prev { from { opacity: 0; translate: -4px 0; } }
|
||||
.stc-enter { animation: stc-enter var(--t-enter) var(--ease-out) both; }
|
||||
|
||||
/* View transitions (route changes, lesson swaps). The old page dissolves over the live new one, whose content
|
||||
plays its own enter animation; the header and the course sidebar are their own groups and do not move.
|
||||
The overlay never takes pointer input, so a click during a transition reaches the new page. */
|
||||
::view-transition { pointer-events: none; }
|
||||
::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; }
|
||||
::view-transition-old(root) { z-index: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
|
||||
::view-transition-new(root) { animation: none; }
|
||||
::view-transition-group(stc-top), ::view-transition-old(stc-top), ::view-transition-new(stc-top),
|
||||
::view-transition-group(lr-side), ::view-transition-old(lr-side), ::view-transition-new(lr-side) { animation: none; mix-blend-mode: normal; }
|
||||
::view-transition-old(stc-top), ::view-transition-old(lr-side) { opacity: 0; }
|
||||
/* the sidebar fades in or out only when the page gains or loses it (library to course and back) */
|
||||
::view-transition-new(lr-side):only-child { animation: stc-fade-in var(--t-enter) var(--ease-out) both; }
|
||||
::view-transition-old(lr-side):only-child { opacity: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
|
||||
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Static motion lint (v1.1 package M): every animation and transition in the shared and learner stylesheets is
|
||||
// light (120–220 ms, ease-out without overshoot), animates only opacity and short translates, and is switched off
|
||||
// by prefers-reduced-motion.
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { learnerCss } from "../../feature-learner/src/web/styles";
|
||||
|
||||
const tokensCss = readFileSync(join(import.meta.dir, "../src/tokens.css"), "utf8");
|
||||
const sheets: [string, string][] = [["tokens.css", tokensCss], ["learnerCss", learnerCss]];
|
||||
const strip = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
/** Duration tokens, in ms. */
|
||||
function tokens(): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const m of strip(tokensCss).matchAll(/(--t-[\w-]+|--fast)\s*:\s*([\d.]+)ms/g)) out[m[1]!] = Number(m[2]);
|
||||
return out;
|
||||
}
|
||||
|
||||
test("the motion duration tokens exist and are 120–220 ms", () => {
|
||||
const t = tokens();
|
||||
for (const name of ["--t-exit", "--t-fast", "--t-enter", "--t-fold", "--t-bar"]) {
|
||||
expect([name, t[name] !== undefined]).toEqual([name, true]);
|
||||
expect(t[name]!).toBeGreaterThanOrEqual(120);
|
||||
expect(t[name]!).toBeLessThanOrEqual(220);
|
||||
}
|
||||
});
|
||||
|
||||
test("every animation and transition duration is 0 or 120–220 ms", () => {
|
||||
const t = tokens();
|
||||
const bad: string[] = [];
|
||||
for (const [name, css] of sheets) {
|
||||
for (const m of strip(css).matchAll(/(?:^|[;{\s])(animation|transition)(?:-duration)?\s*:\s*([^;}]+)/g)) {
|
||||
const value = m[2]!;
|
||||
if (/^\s*none/.test(value)) continue;
|
||||
const durations: number[] = [];
|
||||
for (const d of value.matchAll(/var\((--[\w-]+)\)|(?<![\w.-])([\d.]+)(ms|s)\b/g)) {
|
||||
if (d[1]) { if (d[1] in t) durations.push(t[d[1]]!); continue; }
|
||||
durations.push(Number(d[2]) * (d[3] === "s" ? 1000 : 1));
|
||||
}
|
||||
// The first time in a declaration is the duration; a second one is a delay, which must be 0 or within range too.
|
||||
for (const ms of durations) if (ms !== 0 && (ms < 120 || ms > 220)) bad.push(`${name}: ${m[0].trim()}`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("keyframes animate only opacity and short translates", () => {
|
||||
const bad: string[] = [];
|
||||
let count = 0;
|
||||
for (const [name, css] of sheets) {
|
||||
for (const k of strip(css).matchAll(/@keyframes\s+([\w-]+)\s*\{((?:[^{}]*\{[^}]*\})*)\s*\}/g)) {
|
||||
count++;
|
||||
for (const d of k[2]!.matchAll(/([\w-]+)\s*:\s*([^;}]+)/g)) {
|
||||
const [prop, value] = [d[1]!, d[2]!.trim()];
|
||||
if (prop === "opacity") continue;
|
||||
if (prop === "translate") {
|
||||
const px = [...value.matchAll(/(-?[\d.]+)px/g)].map((x) => Math.abs(Number(x[1])));
|
||||
if (px.every((n) => n <= 8) && !/%|em|vw|vh/.test(value)) continue;
|
||||
}
|
||||
bad.push(`${name} @keyframes ${k[1]}: ${prop}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("easing curves do not overshoot (no bounce)", () => {
|
||||
const bad: string[] = [];
|
||||
for (const [name, css] of sheets) {
|
||||
for (const m of css.matchAll(/cubic-bezier\(([^)]*)\)/g)) {
|
||||
const [, y1, , y2] = m[1]!.split(",").map(Number);
|
||||
if (!(y1! >= 0 && y1! <= 1 && y2! >= 0 && y2! <= 1)) bad.push(`${name}: ${m[0]}`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test("prefers-reduced-motion turns off every animation, transition and view transition", () => {
|
||||
const css = strip(tokensCss);
|
||||
const at = css.indexOf("@media (prefers-reduced-motion: reduce)");
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const block = css.slice(at, css.indexOf("\n}", at));
|
||||
expect(block).toMatch(/\*,\s*\*::before,\s*\*::after/);
|
||||
expect(block).toContain("animation: none !important");
|
||||
expect(block).toContain("transition: none !important");
|
||||
expect(block).toMatch(/::view-transition-group\(\*\)/);
|
||||
expect(block).toMatch(/::view-transition-old\(\*\)/);
|
||||
expect(block).toMatch(/::view-transition-new\(\*\)/);
|
||||
});
|
||||
|
||||
test("view transitions never take pointer input and keep the header and sidebar still", () => {
|
||||
const css = strip(tokensCss);
|
||||
expect(css).toMatch(/::view-transition\s*\{[^}]*pointer-events:\s*none/);
|
||||
expect(css).toMatch(/\.stc-top\s*\{[^}]*view-transition-name:\s*stc-top/);
|
||||
expect(strip(learnerCss)).toMatch(/\.lr-side\s*\{[^}]*view-transition-name:\s*lr-side/);
|
||||
expect(css).toMatch(/::view-transition-group\(stc-top\)[^{]*\{[^}]*animation:\s*none/);
|
||||
});
|
||||
Reference in new issue
Block a user