From c886121dffc35adc7ff2bb7481c9bcfae15a7fee Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 10 Sep 2026 23:56:39 -0700 Subject: [PATCH] =?UTF-8?q?theme:=20transition=20the=20tokens=20themselves?= =?UTF-8?q?=20=E2=80=94=20@property=20makes=20the=20fade=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The element-level transition blanket never could work here: the theme lives in custom properties consumed through color-mix() inside GRADIENTS (background-image is not animatable), inherited colors, and per-frame inline writes. The tokens are now registered as typed @property rules and the .theme-anim class transitions the VARIABLES on the root over 0.75s — every consumer re-resolves each animated frame, gradients and scrims included, with zero per-element transitions to fight hover states or the ruler's per-frame color writes. A forced reflow commits the transition property before the token flip, and watchThemeColors now derives twice (at flip and after settle) so WebGL color targets land on the true final values instead of a mid-fade sample. Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B --- .../artcraft-website-next/src/app/globals.css | 52 ++++++++++++++----- .../src/components/theme-toggle.tsx | 3 ++ .../src/lib/theme-colors.ts | 17 ++++-- 3 files changed, 57 insertions(+), 15 deletions(-) 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); }; }