diff --git a/frontend/apps/artcraft-website-next/src/app/globals.css b/frontend/apps/artcraft-website-next/src/app/globals.css index 7dddbc6d14..819f5e7a45 100644 --- a/frontend/apps/artcraft-website-next/src/app/globals.css +++ b/frontend/apps/artcraft-website-next/src/app/globals.css @@ -124,19 +124,47 @@ body { outline-offset: 2px; } -/* Theme switch cross-fade: the toggle stamps .theme-anim on for - ~0.8s so every themed property (text, backgrounds, hairlines, SVG fills - — the wordmark's logo-A included) transitions together over 0.75s, then - lifts the blanket so hover states and per-frame color writes stay - instant. */ -html.theme-anim, -html.theme-anim * { +/* Theme switch cross-fade. The tokens are registered as typed + custom properties so the VARIABLES THEMSELVES can transition: when the + toggle stamps .theme-anim on , the root transitions every token + over 0.75s and every consumer — utilities, borders, SVG fills, + color-mix() strings, and gradients (background-image is not animatable, + so element-level transitions could never cover the scrims and pools) — + re-resolves each animated frame. No per-element transitions exist, so + hover states and per-frame color writes stay instant throughout. */ +@property --bg { syntax: ""; inherits: true; initial-value: #f2f1ee; } +@property --bg-raised { syntax: ""; inherits: true; initial-value: #faf9f7; } +@property --bg-sunken { syntax: ""; inherits: true; initial-value: #e9e8e4; } +@property --ink { syntax: ""; inherits: true; initial-value: #101014; } +@property --ink-strong { syntax: ""; inherits: true; initial-value: #000000; } +@property --muted { syntax: ""; inherits: true; initial-value: rgba(16, 16, 20, 0.62); } +@property --faint { syntax: ""; inherits: true; initial-value: rgba(16, 16, 20, 0.42); } +@property --line { syntax: ""; inherits: true; initial-value: rgba(16, 16, 20, 0.16); } +@property --line-strong { syntax: ""; inherits: true; initial-value: rgba(16, 16, 20, 0.4); } +@property --accent { syntax: ""; inherits: true; initial-value: #2d81ff; } +@property --accent-ink { syntax: ""; inherits: true; initial-value: #1659c4; } +@property --invert-bg { syntax: ""; inherits: true; initial-value: #101014; } +@property --invert-fg { syntax: ""; inherits: true; initial-value: #f2f1ee; } +@property --danger { syntax: ""; inherits: true; initial-value: #b3261e; } +@property --scanline { syntax: ""; inherits: true; initial-value: rgba(16, 16, 20, 0.03); } + +html.theme-anim { transition: - background-color 0.75s ease, - color 0.75s ease, - border-color 0.75s ease, - fill 0.75s ease, - stroke 0.75s ease; + --bg 0.75s ease, + --bg-raised 0.75s ease, + --bg-sunken 0.75s ease, + --ink 0.75s ease, + --ink-strong 0.75s ease, + --muted 0.75s ease, + --faint 0.75s ease, + --line 0.75s ease, + --line-strong 0.75s ease, + --accent 0.75s ease, + --accent-ink 0.75s ease, + --invert-bg 0.75s ease, + --invert-fg 0.75s ease, + --danger 0.75s ease, + --scanline 0.75s ease; } /* Signature interaction: label blocks invert on hover/active. */ diff --git a/frontend/apps/artcraft-website-next/src/components/theme-toggle.tsx b/frontend/apps/artcraft-website-next/src/components/theme-toggle.tsx index b48070bf17..b347c568cc 100644 --- a/frontend/apps/artcraft-website-next/src/components/theme-toggle.tsx +++ b/frontend/apps/artcraft-website-next/src/components/theme-toggle.tsx @@ -41,6 +41,9 @@ export default function ThemeToggle({ className }: { className?: string }) { () => root.classList.remove("theme-anim"), 800, ); + // Commit the transition property in its own style pass BEFORE the + // token flip — same-recalc changes can skip the transition entirely. + void root.offsetWidth; root.setAttribute("data-theme", next); try { localStorage.setItem(STORAGE_KEY, next); diff --git a/frontend/apps/artcraft-website-next/src/lib/theme-colors.ts b/frontend/apps/artcraft-website-next/src/lib/theme-colors.ts index d291c9df4e..fdf6da0b7f 100644 --- a/frontend/apps/artcraft-website-next/src/lib/theme-colors.ts +++ b/frontend/apps/artcraft-website-next/src/lib/theme-colors.ts @@ -67,17 +67,28 @@ export function watchThemeColors( onChange: (colors: ThemeColors) => void, ): () => void { const derive = () => onChange(deriveThemeColors()); + // Theme flips animate the tokens over 0.75s (see globals.css), so a + // derive at flip time reads MID-transition values. Deliver both: the + // immediate read (consumers that lerp start moving at once) and a final + // read after the fade settles so targets land on the true colors. + let settleTimer: ReturnType | undefined; + const deriveTwice = () => { + derive(); + clearTimeout(settleTimer); + settleTimer = setTimeout(derive, 850); + }; derive(); - const observer = new MutationObserver(derive); + const observer = new MutationObserver(deriveTwice); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); const mq = window.matchMedia("(prefers-color-scheme: dark)"); - mq.addEventListener("change", derive); + mq.addEventListener("change", deriveTwice); return () => { + clearTimeout(settleTimer); observer.disconnect(); - mq.removeEventListener("change", derive); + mq.removeEventListener("change", deriveTwice); }; }