feat(ui): motion tokens, keyframes and reduced-motion switch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 20:32:21 +01:00
1 parent eed59d4ef2
commit 17b6ae3828
3 files changed
+136 -4

No files matched your search

+36 -3
View File
@@ -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; }
}