From 1b126646000468d3f10de4268782a20bdd5805bd Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:39:35 -0700 Subject: [PATCH 01/12] updated bun --- bun.lock | 88 ++++++++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/bun.lock b/bun.lock index c622de1..0bb0452 100644 --- a/bun.lock +++ b/bun.lock @@ -6,51 +6,51 @@ "name": "@weekendsuperhero-io/sistine", "dependencies": { "@phosphor-icons/react": "^2.1.10", - "@tanstack/react-table": "^9.0.0", + "@tanstack/react-table": "^9.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.4.0", "framer-motion": "^13.1.1", - "input-otp": "^1.4.2", - "next": "^16.3.0", + "input-otp": "^1.5.0", + "next": "^16.3.3", "next-themes": "^0.4.6", "radix-ui": "^1.6.7", "react": "^19.2.8", "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", "react-easy-crop": "^6.2.3", - "react-resizable-panels": "^4.12.2", + "react-resizable-panels": "^4.12.3", "recharts": "^3.10.1", - "shadcn": "^4.16.1", - "sonner": "^2.0.7", + "shadcn": "^4.19.0", + "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zod": "^4.4.3", }, "devDependencies": { - "@biomejs/biome": "^2.5.7", - "@chromatic-com/storybook": "^5.2.1", - "@storybook/addon-a11y": "^10.5.6", - "@storybook/addon-docs": "^10.5.6", - "@storybook/addon-onboarding": "^10.5.6", - "@storybook/addon-vitest": "^10.5.6", - "@storybook/nextjs": "^10.5.6", + "@biomejs/biome": "^2.5.10", + "@chromatic-com/storybook": "^5.3.0", + "@storybook/addon-a11y": "^10.5.10", + "@storybook/addon-docs": "^10.5.10", + "@storybook/addon-onboarding": "^10.5.10", + "@storybook/addon-vitest": "^10.5.10", + "@storybook/nextjs": "^10.5.10", "@tailwindcss/postcss": "^4.3.3", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.3", + "@testing-library/user-event": "^14.6.6", "@types/node": "^24.13.3", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.5", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.0", "apca-w3": "^0.1.9", "jsdom": "^30.0.1", - "storybook": "^10.5.6", + "storybook": "^10.5.10", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vitest": "^4.1.10", + "vitest": "^4.1.11", }, }, }, @@ -299,11 +299,11 @@ "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], - "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.0", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A=="], + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.1", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A=="], "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], - "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.8", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg=="], + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.9", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A=="], "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], @@ -457,23 +457,23 @@ "@neoconfetti/react": ["@neoconfetti/react@1.0.0", "", {}, "sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A=="], - "@next/env": ["@next/env@16.3.2", "", {}, "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA=="], + "@next/env": ["@next/env@16.3.3", "", {}, "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1013,7 +1013,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], @@ -1067,7 +1067,7 @@ "camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], - "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "case-sensitive-paths-webpack-plugin": ["case-sensitive-paths-webpack-plugin@2.4.0", "", {}, "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw=="], @@ -1079,7 +1079,7 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "chromatic": ["chromatic@18.5.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "@chromatic-com/cypress": "^0.*.* || ^1.0.0", "@chromatic-com/playwright": "^0.*.* || ^1.0.0", "@chromatic-com/vitest": "^0.*.* || ^1.0.0" }, "optionalPeers": ["@chromatic-com/cypress", "@chromatic-com/playwright", "@chromatic-com/vitest"], "bin": { "chroma": "dist/bin.cjs", "chromatic": "dist/bin.cjs", "chromatic-cli": "dist/bin.cjs" } }, "sha512-3oBcGP4V+6SV0qu2NJnutnPYsrxnPpOHhtQAbzxEtIQrDJNzn1wfXtzwkEiJGQm5eANTW0AvL4WxHtz+BTJbYg=="], + "chromatic": ["chromatic@18.6.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "@chromatic-com/cypress": "^0.*.* || ^1.0.0", "@chromatic-com/playwright": "^0.*.* || ^1.0.0", "@chromatic-com/vitest": "^0.*.* || ^1.0.0" }, "optionalPeers": ["@chromatic-com/cypress", "@chromatic-com/playwright", "@chromatic-com/vitest"], "bin": { "chroma": "dist/bin.cjs", "chromatic": "dist/bin.cjs", "chromatic-cli": "dist/bin.cjs" } }, "sha512-AfZ/rscek7V5SiBOQ2A5Xkns+URf/YoYXGz4ZLUhEu9aCZXGmUE1TY6P+wkjRYxeqQ7+nq5tpZ0dB9UGEQd8WA=="], "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], @@ -1259,7 +1259,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.412", "", {}, "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.414", "", {}, "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw=="], "elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="], @@ -1341,7 +1341,7 @@ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -1427,7 +1427,7 @@ "hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="], - "hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="], + "hono": ["hono@4.13.4", "", {}, "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], @@ -1655,7 +1655,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next": ["next@16.3.2", "", { "dependencies": { "@next/env": "16.3.2", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.2", "@next/swc-darwin-x64": "16.3.2", "@next/swc-linux-arm64-gnu": "16.3.2", "@next/swc-linux-arm64-musl": "16.3.2", "@next/swc-linux-x64-gnu": "16.3.2", "@next/swc-linux-x64-musl": "16.3.2", "@next/swc-win32-arm64-msvc": "16.3.2", "@next/swc-win32-x64-msvc": "16.3.2", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg=="], + "next": ["next@16.3.3", "", { "dependencies": { "@next/env": "16.3.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.3", "@next/swc-darwin-x64": "16.3.3", "@next/swc-linux-arm64-gnu": "16.3.3", "@next/swc-linux-arm64-musl": "16.3.3", "@next/swc-linux-x64-gnu": "16.3.3", "@next/swc-linux-x64-musl": "16.3.3", "@next/swc-win32-arm64-msvc": "16.3.3", "@next/swc-win32-x64-msvc": "16.3.3", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -1751,7 +1751,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], @@ -1997,7 +1997,7 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="], + "systeminformation": ["systeminformation@5.33.3", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-CFdkRa2yfWBXtRtinSm7QbFGRntq1nC2V2hPAjfRTGwncZbNw/UWyiKDT8rZQz6MB70/3ndzP0scIR0dJHJjBA=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -2023,9 +2023,9 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], - "tldts": ["tldts@7.4.10", "", { "dependencies": { "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog=="], + "tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="], - "tldts-core": ["tldts-core@7.4.10", "", {}, "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw=="], + "tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="], "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], @@ -2193,6 +2193,8 @@ "@pmmmwh/react-refresh-webpack-plugin/loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="], + "@reduxjs/toolkit/reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + "@storybook/builder-webpack5/css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="], "@storybook/builder-webpack5/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -2275,6 +2277,8 @@ "htmlparser2/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "jest-worker/@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -2405,6 +2409,8 @@ "fork-ts-checker-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], + "jest-worker/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "md5.js/hash-base/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], diff --git a/package.json b/package.json index c5bc095..dac9735 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "date-fns": "^4.4.0", "framer-motion": "^13.1.1", "input-otp": "^1.5.0", - "next": "^16.3.2", + "next": "^16.3.3", "next-themes": "^0.4.6", "radix-ui": "^1.6.7", "react": "^19.2.8", From 21ae7bd23990f350ac820297598bb76e43e93b80 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:25:12 -0700 Subject: [PATCH 02/12] Done. Four value bugs fixed, plus chakra was missing whole layers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I'd flagged two; reading the CSS turned up four, and a fifth structural one. | # | model had | CSS actually paints | |---|---|---| | 1 | crystal floor `L100` | `--glass-crystal-l: 96` (the token comment even says *"100 carries zero chroma"*) | | 2 | crystal chroma `tintC × 0.6` | `--glass-tint-c-hi × 0.6`, capped at 0.017 — up to **6×** too colorful (tourmaline asked 0.0636 against a 0.0102 ceiling) | | 3 | gloss ink chroma `0` | `min(tintC × --glass-gloss-tint, --glass-gloss-c-max)` — the gloss is **tinted**, and its color was dropped from every crystal/chakra band | | 4 | chakra = `chakra-l → solidify` | `page → chakra-bg@--glass-chakra-a → solidify → wash → gloss` | **#4 is the big one.** `--glass-chakra-bg` is *translucent* (α 0.62/0.58), so the page shows through it, and `--glass-chakra-stack-bg` is the same stack crystal bakes — chakra takes the tint wash and the gloss too. The old model dropped the page, the body's own alpha, the entire wash, **and** the gloss. Confirmed against `utilities.css:56`: `background-image: [hover], [accent], [material stack], [solidify]`, and later image layers paint *below* earlier ones, so solidify is the bottom-most image layer, sitting directly on the background-color. That layer order means **solidify paints over the chakra body**, so `--glass-chakra-l` — documented as *"body/table lightness — its own dial"* — carries only **13% of its nominal authority**: ``` solidify chakra-a L@88 L@12 authority 0.70 0.62 90.5 80.8 13% ← ships 0.70 1.00 89.8 74.1 21% 0.00 0.62 91.9 58.7 44% 0.00 1.00 89.8 33.1 75% ``` A full L88→L12 sweep moves the surface just 9.6 L, and both ends stay light-side of the ~L70 crossover — so the text never flips polarity. That's a CSS design question (should chakra reduce or drop solidify, as `opaque` does?), not something the model should paper over. `tests/auto-foreground.test.tsx` had a test asserting that sweep *does* flip the text. It only passed because the old model treated `chakra-l` as an opaque base. I didn't just delete it — I split it in two: one asserting the direction and magnitude it genuinely has (darker body → darker ink, moving >5 L), and a new one that drops solidify and opaques the body so `chakra-l` drives 75% and the flip is real. That keeps the original intent — *the tier must band against its own surface* — testable. The two surface-parity failures were self-inflicted: that file keeps its **own mirror** of the models, annotated *"Kept in step with the applyTiers() call sites"*. I updated the mirror; it wasn't a threshold relaxation. **155 tests pass** (was 154), lint, typecheck, theme invariants, gamut, and the APCA oracle all green. Impact is small on crystal (≤0.2 L, chroma slightly up now the gloss carries its tint) and real on chakra — up to **2.1 L darker** on the jewels, and 3.4 L *lighter* on peridot, since it now sees the wash. `check-contrast` still fails on the same five presets from before — amethyst/rose/tourmaline/goldstone margins plus `destructive` under the body floor. Untouched by this work; those are normal-surface, and the alpha values to clear them are still the ones from last turn. Want me to apply those now? I should flag one thing I deliberately left out: the gloss **streak** (0.15α) and **glow** (0.2α). The glow is past its 70% fade before it reaches the title zone, and the streak's mean there is a geometry estimate, not a measurement — both would only add light, so omitting them is the conservative direction. And I still haven't verified the gamma-sRGB compositing assumption against a real browser paint; there's no Playwright here. --- components/auto-foreground.tsx | 140 ++++++++++++----- lib/oklch-utils.ts | 141 +++++++++++++++--- tests/auto-foreground-surface-parity.test.tsx | 52 +++++-- tests/auto-foreground.test.tsx | 54 +++++-- 4 files changed, 306 insertions(+), 81 deletions(-) diff --git a/components/auto-foreground.tsx b/components/auto-foreground.tsx index 0db0da4..3e029a2 100644 --- a/components/auto-foreground.tsx +++ b/components/auto-foreground.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { apcaContrast, + compositeSurface, formatOklch, glassSolidSurface, HARMONIC_OFFSETS, @@ -279,8 +280,37 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor c: Math.min(tintC * num("--glass-opaque-c-scale", dark ? 1.05 : 0.85), num("--glass-opaque-c-max", dark ? 0.12 : 0.055)), a: glassOpacity, }; - /** Composite the solidify floor over a sheer floor lightness — the layer order `glass` paints. */ - const solidified = (l: number) => l * (1 - glassOpacity) + solidifyFloor.l * glassOpacity; + /** The solidify floor as a compositable layer — the layer order `glass` paints. */ + const solidifyLayer = { + l: solidifyFloor.l, + c: solidifyFloor.c, + h: tintH, + a: solidifyFloor.a, + }; + /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The + sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at + near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers + rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */ + const TINT_C_HI_MAX = 0.017; + const tintCHi = Math.min(tintC, TINT_C_HI_MAX); + /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so + it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by + 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band + unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred + at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches + the title zone, and the streak's mean there is a geometry estimate rather than a measurement. + Both would only ADD light, so omitting them is the conservative direction. */ + const GLOSS_TOP_A = 0.2; + const glossLayer = { + /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would + model the light crystal surface ~6 L darker than it renders and band text too weak. */ + l: num("--glass-gloss-l", dark ? 66 : 97), + // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max). + // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band. + c: Math.min(tintC * num("--glass-gloss-tint", 4.25), num("--glass-gloss-c-max", dark ? 0.109 : 0.013)), + h: tintH, + a: GLOSS_TOP_A, + }; const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1); const normalLcBoost = LC_MARGIN * showThrough; const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH); @@ -490,57 +520,89 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's - // MEAN gloss term (the top highlight peaks at 0.4α and fades out by 30% height → ≈0.2 effective; - // modeling the 0.4 peak would make the band unsatisfiable on mid-gray). [data-material="crystal"] - // and the crystal page style remap the tiers to this set — except veiled crystal, whose floor is - // what the NORMAL tiers are banded for. Same show-through margin logic: the backdrop's weight in - // this mix is (1−crysA)(1−tintA)(1−glossA). + // mean gloss term (see glossLayer). [data-material="crystal"] and the crystal page style remap the + // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for. + // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA). { const crysA = num("--glass-crystal-bg-a", dark ? 0.1 : 0.3); - /* Mode-aware fallback: --glass-gloss-l is a twin now (97 light / 66 dark), so a single 66 here - would model the light crystal surface ~6 L darker than it renders and band text too weak. */ - const glossL = num("--glass-gloss-l", dark ? 66 : 97); - const GLOSS_TOP_A = 0.2; // mean of the 0.4α top highlight across the title zone - const baseL = dark ? 20 : 95; // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step - // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. - const floorL = solidified(baseL * (1 - crysA) + 100 * crysA); - const washedL = floorL * (1 - tintA) + washL * tintA; - const crystalL = washedL * (1 - GLOSS_TOP_A) + glossL * GLOSS_TOP_A; + // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer + // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this + // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it. const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1); applyTiers( - { - l: crystalL, - // The crystal floor's own chroma plus the solidify floor's, each surviving what the layers - // above let through — the sheer 0.6× term alone assumed nothing solid sat underneath. - c: (num("--glass-tint-c", 0) * 0.6 * (1 - glassOpacity) + solidifyFloor.c * glassOpacity) * (1 - tintA) + tintC * washCMult * tintA, - h: tintH, - }, + compositeSurface( + { + l: dark ? 20 : 95, + c: 0, + h: tintH, + }, + [ + // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This + // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why + // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that + // renders (tourmaline 0.0636 vs the 0.0102 ceiling). + { + l: num("--glass-crystal-l", 96), + c: tintCHi * 0.6, + h: tintH, + a: crysA, + }, + solidifyLayer, + { + l: washL, + c: tintC * washCMult, + h: tintH, + a: tintA, + }, + glossLayer, + ], + ), "-crystal", true, LC_MARGIN * uCrystal, ); } - // Chakra cards: content sits on the translucent body, so --glass-chakra-l IS the banding - // lightness. The facet bands need no term — they live in box-shadow, ride the outer few px of the - // edge, and pair a highlight against an ink on opposite sides, so they contribute nothing where - // text actually sits. Show-through is just (1 − body a), the largest of any tier here, so this - // gets the full margin. `adaptive` for the same reason opaque is: an L88 body can sit on a dark - // page, and the theme ramp only spans the readable half so it cannot produce dark text. + // Chakra cards: content sits on the translucent body, banded against the whole stack it paints. + // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and + // pair a highlight against an ink on opposite sides, so they contribute nothing where text + // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any + // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body + // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text. { const bodyA = num("--glass-chakra-a", dark ? 0.58 : 0.62); - // The body is the background-COLOR, so solidify paints over it here too. Chakra happened to land - // right on moonstone night only because that preset pins --glass-chakra-l light (84) to match its - // cream opaque floor — the model was not actually tracking the floor, it just agreed with it. + /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light + / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows + through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco + slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss + too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire + wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that + preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */ const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1); applyTiers( - { - l: solidified(num("--glass-chakra-l", dark ? 28 : 88)), - c: - Math.min(num("--glass-tint-c", 0), num("--glass-chakra-c-max", dark ? 0.046 : 0.055)) * (1 - glassOpacity) + - solidifyFloor.c * glassOpacity, - h: tintH, - }, + compositeSurface( + { + l: dark ? 20 : 95, + c: 0, + h: tintH, + }, + [ + { + l: num("--glass-chakra-l", dark ? 28 : 88), + c: Math.min(tintC, num("--glass-chakra-c-max", dark ? 0.046 : 0.055)), + h: tintH, + a: bodyA, + }, + solidifyLayer, + { + l: washL, + c: tintC * washCMult, + h: tintH, + a: tintA, + }, + glossLayer, + ], + ), "-chakra", true, LC_MARGIN * uChakra, diff --git a/lib/oklch-utils.ts b/lib/oklch-utils.ts index d3c3bea..f19d3af 100644 --- a/lib/oklch-utils.ts +++ b/lib/oklch-utils.ts @@ -693,6 +693,81 @@ export function oklchToSrgb( ]; } +/** Gamma-encoded sRGB channels in [0, 1] → OKLCH (l 0–100). Exact inverse of {@link oklchToSrgb} for + * in-gamut input, so a composite→decompose round trip is lossless. */ +export function srgbToOklch([r, g, b]: [ + number, + number, + number, +]): OklchColor { + const decode = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + const [lr, lg, lb] = [ + decode(r), + decode(g), + decode(b), + ]; + const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; + const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; + const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; + return { + l: L * 100, + c: Math.hypot(a, bb), + h: wrapHue((Math.atan2(bb, a) * 180) / Math.PI), + }; +} + +/** + * Composite a stack of translucent OKLCH layers THE WAY THE BROWSER DOES — CSS source-over on + * gamma-encoded sRGB channels — and report the result in OKLCH. + * + * The obvious shortcut is to lerp the OKLCH coordinates instead (`l1·(1−a) + l2·a`, same for chroma). + * That is a different operation and it is measurably wrong: OKLCH lightness is a cube-root-ish + * perceptual scale, sRGB is a ~2.2 gamma, and chroma is a POLAR coordinate, so lerping it mixes two + * hues along a straight line through the chroma plane rather than around it. Measured against the + * shipped presets, the shortcut overstated light-mode body contrast by up to 2.8 Lc on the veiled + * surface — enough to hide four presets sitting under the ARC body floor — while on the crystal + * surface the error CHANGES SIGN by preset (+0.95 carnelian, −2.66 aventurine). That sign flip is why + * this is a compositing fix and not a fudge factor: no single correction term can be both. + * + * `layers` paint bottom-first; `base` is fully opaque. Each layer's alpha is its own (a layer at + * alpha 1 simply replaces what is under it). Hue is carried through from the top-most layer that + * actually contributed chroma, so a neutral stack keeps its nominal hue instead of collapsing to 0. + */ +export function compositeSurface( + base: OklchColor, + layers: Array< + OklchColor & { + a: number; + } + >, +): OklchColor { + let px = oklchToSrgb(base.l, base.c, base.h); + let hue = base.h; + for (const layer of layers) { + const a = Math.min(Math.max(layer.a, 0), 1); + if (a <= 0) continue; + const src = oklchToSrgb(layer.l, layer.c, layer.h); + px = [ + src[0] * a + px[0] * (1 - a), + src[1] * a + px[1] * (1 - a), + src[2] * a + px[2] * (1 - a), + ]; + if (layer.c > 0) hue = layer.h; + } + const out = srgbToOklch(px); + // Below this the hue angle is numerical noise off a near-neutral pixel — keep the stack's nominal + // hue so callers that tint from the surface (readableForeground, the ramp base) stay on the theme. + return out.c < 1e-4 + ? { + ...out, + h: hue, + } + : out; +} + /** APCA screen luminance (Ys) from gamma-encoded sRGB. */ function apcaLuminance([r, g, b]: [ number, @@ -791,11 +866,21 @@ export function glassSurface( ): OklchColor { const baseL = dark ? 20 : 95; const washL = dark ? 58 : 72; - return { - l: baseL * (1 - tint.a) + washL * tint.a, - c: tint.c * 2.5 * tint.a, - h: tint.h, - }; + return compositeSurface( + { + l: baseL, + c: 0, + h: tint.h, + }, + [ + { + l: washL, + c: tint.c * 2.5, + h: tint.h, + a: tint.a, + }, + ], + ); } /** @@ -830,19 +915,39 @@ export function glassSolidSurface( ): OklchColor { const baseL = dark ? 20 : 95; const solidL = dark ? 18 : 99; - let floorL = baseL * (1 - solidA) + solidL * solidA; - let floorC = 0; - if (solidify) { - floorL = floorL * (1 - solidify.a) + solidify.l * solidify.a; - floorC = solidify.c * solidify.a; - } - return { - l: floorL * (1 - tint.a) + washL * tint.a, - // The wash composites OVER the solidified floor, so the floor's own chroma survives in proportion - // to what the wash lets through — same mix the lightness above takes. - c: floorC * (1 - tint.a) + tint.c * washCMult * tint.a, - h: tint.h, - }; + // The real paint order, composited in the real space — see compositeSurface for why lerping the + // OKLCH coordinates instead is not the same operation. + return compositeSurface( + { + l: baseL, + c: 0, + h: tint.h, + }, + [ + { + l: solidL, + c: 0, + h: tint.h, + a: solidA, + }, + ...(solidify + ? [ + { + l: solidify.l, + c: solidify.c, + h: tint.h, + a: solidify.a, + }, + ] + : []), + { + l: washL, + c: tint.c * washCMult, + h: tint.h, + a: tint.a, + }, + ], + ); } /** Options for {@link themeForeground}. */ diff --git a/tests/auto-foreground-surface-parity.test.tsx b/tests/auto-foreground-surface-parity.test.tsx index 4b94cae..a05ff65 100644 --- a/tests/auto-foreground-surface-parity.test.tsx +++ b/tests/auto-foreground-surface-parity.test.tsx @@ -18,7 +18,7 @@ import { cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AutoForeground } from "@/components/auto-foreground"; -import { apcaContrast, glassSolidSurface, type OklchColor, parseOklch } from "@/lib/oklch-utils"; +import { apcaContrast, compositeSurface, glassSolidSurface, type OklchColor, parseOklch } from "@/lib/oklch-utils"; const root = () => document.documentElement; const read = (name: string) => root().style.getPropertyValue(name).trim(); @@ -48,13 +48,24 @@ const surfaces = (dark: boolean): Record => { // here because jsdom resolves --glass-tint-c to its 0 fallback. const opacity = 0.7; const opaqueL = dark ? 36.4 : 88; - const solidified = (l: number) => l * (1 - opacity) + opaqueL * opacity; const solidify = { l: opaqueL, c: 0, + h, a: opacity, }; - const floorL = solidified((dark ? 20 : 95) * (1 - crysA) + 100 * crysA); + const page = { + l: dark ? 20 : 95, + c: 0, + h, + }; + // Same gloss ink both sheer materials bake; chroma 0 here because jsdom resolves --glass-tint-c to 0. + const gloss = { + l: glossL, + c: 0, + h, + a: GLOSS_TOP_A, + }; return { "": glassSolidSurface( dark, @@ -73,16 +84,31 @@ const surfaces = (dark: boolean): Record => { c: 0, h, }, - "-crystal": { - l: floorL * (1 - GLOSS_TOP_A) + glossL * GLOSS_TOP_A, - c: 0, - h, - }, - "-chakra": { - l: solidified(dark ? 28 : 88), - c: 0, - h, - }, + // --glass-crystal-bg over the page, then solidify → wash → gloss. Composited in sRGB, the way the + // browser paints it; the crystal floor is --glass-crystal-l (96), NOT a flat L100. + "-crystal": compositeSurface(page, [ + { + l: 96, + c: 0, + h, + a: crysA, + }, + solidify, + gloss, + ]), + // The chakra BODY is translucent (--glass-chakra-a), so the page shows through it, and chakra bakes + // the same wash + gloss stack crystal does. The wash is absent here only because jsdom leaves + // --glass-tint-a at its 0 fallback. + "-chakra": compositeSurface(page, [ + { + l: dark ? 28 : 88, + c: 0, + h, + a: dark ? 0.58 : 0.62, + }, + solidify, + gloss, + ]), }; }; diff --git a/tests/auto-foreground.test.tsx b/tests/auto-foreground.test.tsx index b3e7812..93e1222 100644 --- a/tests/auto-foreground.test.tsx +++ b/tests/auto-foreground.test.tsx @@ -90,21 +90,53 @@ describe("AutoForeground: chakra tiers", () => { expect(darkBody).toBeGreaterThan(50); // light ink on the L28 body }); - it("tracks --glass-chakra-l rather than assuming the default body", async () => { - /* The banding lightness is read from the token. Driving the body from L88 to a near-black L12 - must move the solved text the other way, or the tier is ignoring its own surface. */ - root().style.setProperty("--glass-chakra-l", "88"); - render(); - await waitFor(() => expect(read("--foreground-chakra")).not.toBe("")); - const onLightBody = lightnessOf(read("--foreground-chakra")); - + /** Solve --foreground-chakra with an arbitrary set of root vars pinned. */ + const chakraWith = async (vars: Record) => { root().removeAttribute("style"); - root().style.setProperty("--glass-chakra-l", "12"); + for (const [k, v] of Object.entries(vars)) root().style.setProperty(k, v); render(); await waitFor(() => expect(read("--foreground-chakra")).not.toBe("")); - const onDarkBody = lightnessOf(read("--foreground-chakra")); + return lightnessOf(read("--foreground-chakra")); + }; + + it("tracks --glass-chakra-l rather than assuming the default body", async () => { + /* The banding lightness is read from the token, but it is only ONE layer of the chakra stack: the + body is translucent (--glass-chakra-a) AND the solidify floor paints OVER it at --glass-opacity, + so at shipped defaults the token carries just 13% of its nominal authority (a full L88 → L12 + sweep moves the composited surface only L90.5 → L80.8). Both ends stay light-side of the ~L70 + polarity crossover, so the text stays DARK and gets darker — it does not flip. Assert the + direction it actually has: a darker body means less reach for dark ink, so the pick goes down. */ + const onLightBody = await chakraWith({ + "--glass-chakra-l": "88", + }); + const onDarkBody = await chakraWith({ + "--glass-chakra-l": "12", + }); + + expect(onLightBody).toBeGreaterThan(onDarkBody); + expect(onLightBody - onDarkBody).toBeGreaterThan(5); // the token must MOVE the solve, not be ignored + }); - expect(onLightBody).toBeLessThan(onDarkBody); + it("lets --glass-chakra-l flip the text once the layers above it are out of the way", async () => { + /* The polarity flip the token can't reach on its own. Drop the solidify floor (--glass-opacity: 0) + and make the body opaque, and --glass-chakra-l drives 75% of the surface — enough to cross the + crossover, at which point `adaptive` must switch the ink to light. This pins that the chakra tier + is genuinely banding against its own composited surface rather than a fixed assumption. */ + const solo = { + "--glass-opacity": "0", + "--glass-chakra-a": "1", + }; + const onLightBody = await chakraWith({ + ...solo, + "--glass-chakra-l": "88", + }); + const onDarkBody = await chakraWith({ + ...solo, + "--glass-chakra-l": "12", + }); + + expect(onLightBody).toBeLessThan(50); // dark ink on the L88 body + expect(onDarkBody).toBeGreaterThan(50); // light ink once the body actually goes dark }); }); From 62efe726c4b334bb0dc9e0290121f176de349a65 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:26:16 -0700 Subject: [PATCH 03/12] updated registry --- public/r/auto-foreground.json | 2 +- public/r/oklch-utils.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json index 446560d..88d79a7 100644 --- a/public/r/auto-foreground.json +++ b/public/r/auto-foreground.json @@ -11,7 +11,7 @@ "files": [ { "path": "components/auto-foreground.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const solidifyFloor = {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055)),\n a: glassOpacity,\n };\n /** Composite the solidify floor over a sheer floor lightness — the layer order `glass` paints. */\n const solidified = (l: number) => l * (1 - glassOpacity) + solidifyFloor.l * glassOpacity;\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: huelessAccent ? accentH : tintH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: \"\" for the\n // normal glass-SOLID surface (page + translucent/solid cards), and \"-opaque\" for the solid\n // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars\n // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while\n // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own.\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n hue: huelessAccent ? accentH : tintH,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE[usage]),\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : tintH;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE.ui),\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer.\n applyTiers(\n glassSolidSurface(\n dark,\n {\n h: tintH,\n c: num(\"--glass-tint-c\", 0),\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n ),\n \"\",\n false,\n normalLcBoost,\n );\n // Opaque cards paint the solid --glass-opaque-bg floor — band a second set against it. This IS the\n // solidify floor, undiluted: an opaque card is the one surface with nothing sheer above it, so it is\n // `solidifyFloor` at full strength. Reuse it rather than re-deriving, which is how this drifted — it\n // carried its own `* 0.9` (a multiplier matching neither mode: tokens.css ships 0.85 light / 1.05\n // dark), its own stale lightness, and no chroma cap at all, so the opaque tier banded against a\n // surface the CSS never paints. One derivation, one place to keep in step with tokens.css.\n // `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n // readable half and can't.\n applyTiers(\n {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // MEAN gloss term (the top highlight peaks at 0.4α and fades out by 30% height → ≈0.2 effective;\n // modeling the 0.4 peak would make the band unsatisfiable on mid-gray). [data-material=\"crystal\"]\n // and the crystal page style remap the tiers to this set — except veiled crystal, whose floor is\n // what the NORMAL tiers are banded for. Same show-through margin logic: the backdrop's weight in\n // this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n /* Mode-aware fallback: --glass-gloss-l is a twin now (97 light / 66 dark), so a single 66 here\n would model the light crystal surface ~6 L darker than it renders and band text too weak. */\n const glossL = num(\"--glass-gloss-l\", dark ? 66 : 97);\n const GLOSS_TOP_A = 0.2; // mean of the 0.4α top highlight across the title zone\n const baseL = dark ? 20 : 95;\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints.\n const floorL = solidified(baseL * (1 - crysA) + 100 * crysA);\n const washedL = floorL * (1 - tintA) + washL * tintA;\n const crystalL = washedL * (1 - GLOSS_TOP_A) + glossL * GLOSS_TOP_A;\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n {\n l: crystalL,\n // The crystal floor's own chroma plus the solidify floor's, each surviving what the layers\n // above let through — the sheer 0.6× term alone assumed nothing solid sat underneath.\n c: (num(\"--glass-tint-c\", 0) * 0.6 * (1 - glassOpacity) + solidifyFloor.c * glassOpacity) * (1 - tintA) + tintC * washCMult * tintA,\n h: tintH,\n },\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, so --glass-chakra-l IS the banding\n // lightness. The facet bands need no term — they live in box-shadow, ride the outer few px of the\n // edge, and pair a highlight against an ink on opposite sides, so they contribute nothing where\n // text actually sits. Show-through is just (1 − body a), the largest of any tier here, so this\n // gets the full margin. `adaptive` for the same reason opaque is: an L88 body can sit on a dark\n // page, and the theme ramp only spans the readable half so it cannot produce dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n // The body is the background-COLOR, so solidify paints over it here too. Chakra happened to land\n // right on moonstone night only because that preset pins --glass-chakra-l light (84) to match its\n // cream opaque floor — the model was not actually tracking the floor, it just agreed with it.\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n {\n l: solidified(num(\"--glass-chakra-l\", dark ? 28 : 88)),\n c:\n Math.min(num(\"--glass-tint-c\", 0), num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)) * (1 - glassOpacity) +\n solidifyFloor.c * glassOpacity,\n h: tintH,\n },\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const solidifyFloor = {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: huelessAccent ? accentH : tintH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: \"\" for the\n // normal glass-SOLID surface (page + translucent/solid cards), and \"-opaque\" for the solid\n // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars\n // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while\n // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own.\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n hue: huelessAccent ? accentH : tintH,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE[usage]),\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : tintH;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE.ui),\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer.\n applyTiers(\n glassSolidSurface(\n dark,\n {\n h: tintH,\n c: num(\"--glass-tint-c\", 0),\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n ),\n \"\",\n false,\n normalLcBoost,\n );\n // Opaque cards paint the solid --glass-opaque-bg floor — band a second set against it. This IS the\n // solidify floor, undiluted: an opaque card is the one surface with nothing sheer above it, so it is\n // `solidifyFloor` at full strength. Reuse it rather than re-deriving, which is how this drifted — it\n // carried its own `* 0.9` (a multiplier matching neither mode: tokens.css ships 0.85 light / 1.05\n // dark), its own stale lightness, and no chroma cap at all, so the opaque tier banded against a\n // surface the CSS never paints. One derivation, one place to keep in step with tokens.css.\n // `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n // readable half and can't.\n applyTiers(\n {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", "type": "registry:component", "target": "components/auto-foreground.tsx" } diff --git a/public/r/oklch-utils.json b/public/r/oklch-utils.json index 45d09ed..9d03a79 100644 --- a/public/r/oklch-utils.json +++ b/public/r/oklch-utils.json @@ -8,7 +8,7 @@ "files": [ { "path": "lib/oklch-utils.ts", - "content": "/**\n * OKLCH color primitives — parse / format an oklch string and generate symmetric ramps\n * (hue or chroma) around an existing color. Dependency-free; shares the {l, c, h} shape with\n * `GradientColor` in gradient-utils.ts.\n */\n\nexport interface OklchColor {\n /** Lightness, 0–100 (%) */\n l: number;\n /** Chroma, 0–~0.37 */\n c: number;\n /** Hue, 0–360 (degrees) */\n h: number;\n /** Optional alpha, 0–1 */\n alpha?: number;\n}\n\n/**\n * Practical oklch chroma ceiling. Both sRGB and Display-P3 colors stay below ~0.37 (per Evil\n * Martians; confirmed by sweeping every L×hue with the gamut math below — sRGB max ≈ 0.321,\n * P3 max ≈ 0.363). Used as the binary-search bound and the chroma-ramp default range.\n */\nexport const MAX_CHROMA = 0.37;\n\n/** Wrap a hue into [0, 360). */\nexport function wrapHue(h: number): number {\n return ((h % 360) + 360) % 360;\n}\n\n/** Clamp chroma into [0, MAX_CHROMA]. */\nexport function clampChroma(c: number): number {\n return Math.max(0, Math.min(MAX_CHROMA, c));\n}\n\n/** A bare lightness ≤ 1 is the 0–1 form (×100); otherwise it's already 0–100. */\nfunction normalizeLightness(l: number): number {\n return l <= 1 ? l * 100 : l;\n}\n\n/**\n * Parse an `oklch()` string into components. Handles both lightness conventions used in this\n * repo — `oklch(72% 0.05 255)` and `oklch(0.72 0.05 255)` — plus an optional `/ alpha`.\n * Returns null if the string isn't a parseable oklch().\n */\nexport function parseOklch(input: string): OklchColor | null {\n const match = /^\\s*oklch\\(([^)]+)\\)\\s*$/i.exec(input);\n if (!match) return null;\n\n const [coords, alphaPart] = match[1].split(\"/\");\n const parts = coords.trim().split(/\\s+/);\n if (parts.length < 3) return null;\n\n const lRaw = parts[0];\n const l = lRaw.endsWith(\"%\") ? Number.parseFloat(lRaw) : normalizeLightness(Number.parseFloat(lRaw));\n const c = Number.parseFloat(parts[1]);\n const h = Number.parseFloat(parts[2]);\n if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) return null;\n\n const color: OklchColor = {\n l,\n c,\n h,\n };\n if (alphaPart !== undefined) {\n const a = Number.parseFloat(alphaPart.trim());\n if (Number.isFinite(a)) color.alpha = a;\n }\n return color;\n}\n\n/** Format components back into an `oklch()` string (lightness as %). */\nexport function formatOklch(color: OklchColor, alpha?: number): string {\n const a = alpha ?? color.alpha;\n const base = `${color.l.toFixed(1)}% ${color.c.toFixed(3)} ${color.h.toFixed(1)}`;\n return a === undefined ? `oklch(${base})` : `oklch(${base} / ${a})`;\n}\n\n/** Coerce a base argument (string | OklchColor) into components; throws on an unparseable string. */\nfunction toColor(base: OklchColor | string): OklchColor {\n if (typeof base !== \"string\") return base;\n const parsed = parseOklch(base);\n if (!parsed) throw new Error(`oklch-utils: could not parse \"${base}\"`);\n return parsed;\n}\n\n/** Per-side step count, clamped to a sane [3, 12] (rounded). */\nfunction clampCount(count: number): number {\n return Math.max(3, Math.min(12, Math.round(count)));\n}\n\n/**\n * A full-range value ramp: `count` steps each side of `seed`, reaching `min` on the left and\n * `max` on the right, with the seed held at the center index. Step sizes differ per side (the\n * seed is rarely the midpoint), so the whole [min, max] range is always covered — more steps\n * just sample it finer. Returns `2 * count + 1` values, min → seed → max.\n */\nfunction rangeRamp(seed: number, min: number, max: number, count: number): number[] {\n const leftStep = (seed - min) / count;\n const rightStep = (max - seed) / count;\n const out: number[] = [];\n for (let i = count; i >= 1; i--) out.push(seed - leftStep * i);\n out.push(seed);\n for (let i = 1; i <= count; i++) out.push(seed + rightStep * i);\n return out;\n}\n\n/**\n * Hue ramp covering the FULL wheel: `count` steps each side, the seed centered, hues spread evenly\n * around the wheel (step = 360 / (2·count + 1)) and wrapped into [0, 360). Lightness + chroma are\n * held; `count` clamped to [3, 12]. Unlike chroma/lightness, hue is cyclic — 0° ≡ 360° — so the ramp\n * is distributed cyclically instead of running to both endpoints, which keeps the two edge swatches\n * distinct. e.g. seed 120, count 4 → 320, 0, 40, 80, 120, 160, 200, 240, 280.\n */\nexport function hueRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n const n = clampCount(count);\n const seed = wrapHue(color.h);\n const step = 360 / (2 * n + 1);\n const out: OklchColor[] = [];\n for (let k = -n; k <= n; k++) {\n out.push({\n ...color,\n h: wrapHue(seed + k * step),\n });\n }\n return out;\n}\n\n/** Full-wheel hue ramp as CSS oklch strings (the seed sits at the center index). */\nexport function hueRamp(base: OklchColor | string, count: number): string[] {\n return hueRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Hue harmonies ─────────────────────────────────────────────────────────────\n// Rotate the base hue to derive complementary / harmonious colors (lightness + chroma held). oklch\n// hue is perceptually even, so a 180° \"complement\" is a balanced opposite — not the skewed HSL/RGB one.\n\n/** The perceptual complement: same L + C, hue rotated 180° (alpha preserved), gamut-clamped — the\n * 180° case of {@link harmony}. */\nexport function complement(base: OklchColor | string, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n return harmony(\n base,\n [\n 180,\n ],\n gamut,\n )[1];\n}\n\n/**\n * Hue-harmony set: the base plus a color at each rotation `angle` in degrees (same L + C),\n * gamut-clamped. Returns `[base, …rotations]`. Common sets — analogous `[-30, 30]`, triadic\n * `[120, 240]`, split-complement `[150, 210]`, tetradic `[90, 180, 270]`.\n */\nexport function harmony(base: OklchColor | string, angles: number[], gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n const c = toColor(base);\n return [\n clampToGamut(c, gamut),\n ...angles.map((a) =>\n clampToGamut(\n {\n ...c,\n h: wrapHue(c.h + a),\n },\n gamut,\n ),\n ),\n ];\n}\n\n/** Named color-wheel relationships — the JS mirror of the --hue-* tokens in app/globals.css. */\nexport type HarmonicName =\n | \"base\"\n | \"complement\"\n | \"analogous-1\"\n | \"analogous-2\"\n | \"split-1\"\n | \"split-2\"\n | \"triad-1\"\n | \"triad-2\"\n | \"tetrad-1\"\n | \"tetrad-2\"\n | \"tetrad-3\"\n | \"square-1\"\n | \"square-2\"\n | \"square-3\";\n\n/**\n * Hue offsets (degrees) for each color-wheel relationship. MUST stay in lockstep with the `--hue-*`\n * tokens in app/globals.css (`calc(var(--harmony-h) + N)`) so a JS-solved icon/foreground hue lands on\n * the SAME angle as the CSS harmonic swatches. Pair with `harmonicHue()` + `readableForeground()` to get\n * a contrast-solved (APCA/ARC) color at the harmonic angle.\n */\nexport const HARMONIC_OFFSETS: Record = {\n base: 0,\n complement: 180,\n \"analogous-1\": -30,\n \"analogous-2\": 30,\n \"split-1\": 150,\n \"split-2\": 210,\n \"triad-1\": 120,\n \"triad-2\": 240,\n \"tetrad-1\": 60,\n \"tetrad-2\": 180,\n \"tetrad-3\": 240,\n \"square-1\": 90,\n \"square-2\": 180,\n \"square-3\": 270,\n};\n\n/** All harmonic relationship names, in wheel order (for building pickers). */\nexport const HARMONIC_NAMES = Object.keys(HARMONIC_OFFSETS) as HarmonicName[];\n\n/**\n * Rotate an anchor hue by a named color-wheel relationship, wrapped to [0, 360). The anchor is normally\n * the harmony hue (--harmony-h: the content hue, or 0 for the hue-less selenite/moonstone themes). Feed the\n * result to `readableForeground({ hue })` to keep the contrast solve while landing on the harmonic angle.\n */\nexport function harmonicHue(anchorH: number, name: HarmonicName): number {\n return wrapHue(anchorH + HARMONIC_OFFSETS[name]);\n}\n\n/**\n * Chroma ramp covering [0, cap]: `count` steps each side, the seed centered (left → 0, right →\n * cap). Lightness + hue are held; `count` clamped to [3, 12]. `max` defaults to the largest chroma\n * actually DISPLAYABLE for this L+hue in sRGB (≈0.32, varies by L/hue) — so the ramp reaches the\n * visible edge instead of a flat MAX_CHROMA that just clamps on screen. Pass `maxP3Chroma(l, h)` on\n * P3-capable displays for a punchier top end, or any explicit number to sweep a custom range.\n */\nexport function chromaRampColors(base: OklchColor | string, count: number, max?: number): OklchColor[] {\n const color = toColor(base);\n const cap = max ?? maxSrgbChroma(color.l, color.h);\n const seed = Math.max(0, Math.min(cap, color.c));\n return rangeRamp(seed, 0, cap, clampCount(count)).map((c) => ({\n ...color,\n c,\n }));\n}\n\n/** Chroma ramp as CSS oklch strings (the seed sits at the center index). */\nexport function chromaRamp(base: OklchColor | string, count: number, max?: number): string[] {\n return chromaRampColors(base, count, max).map((color) => formatOklch(color));\n}\n\n/**\n * Lightness ramp covering the FULL range [0, 100]: `count` steps each side, the seed centered\n * (left → 0, right → 100). Chroma + hue are held; `count` clamped to [3, 12].\n */\nexport function lightnessRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n return rangeRamp(color.l, 0, 100, clampCount(count)).map((l) => ({\n ...color,\n l,\n }));\n}\n\n/** Full-range lightness ramp as CSS oklch strings (the seed sits at the center index). */\nexport function lightnessRamp(base: OklchColor | string, count: number): string[] {\n return lightnessRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Gamut-aware tonal scale ───────────────────────────────────────────────────\n// A tonal scale (the Radix/Tailwind 1→N pattern) is NOT a chroma shift: lightness eases\n// light→dark while chroma rises with the scale but is capped by the sRGB gamut — so chroma is\n// near-zero in the light tints, peaks in the mid-dark \"brand\" steps, then tapers in the darkest\n// steps (because dark colors can't physically hold as much chroma). The gamut cap is what\n// produces that peak-then-fall curve; a flat chroma shift cannot.\n\n/** OKLCH (l 0–100) → linear sRGB channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearSrgb(\n l: number,\n c: number,\n hDeg: number,\n): [\n number,\n number,\n number,\n] {\n const L = l / 100;\n const hr = (hDeg * Math.PI) / 180;\n const a = c * Math.cos(hr);\n const b = c * Math.sin(hr);\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.291485548 * b;\n const ll = l_ * l_ * l_;\n const mm = m_ * m_ * m_;\n const ss = s_ * s_ * s_;\n return [\n 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss,\n -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss,\n -0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the sRGB gamut. */\nexport function inSrgbGamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearSrgb(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** OKLCH (l 0–100) → linear Display-P3 channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearP3(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n // linear sRGB → linear Display-P3 (same color, wider basis) — valid even for out-of-sRGB values.\n const [sr, sg, sb] = oklchToLinearSrgb(l, c, h);\n return [\n 0.8224621 * sr + 0.177538 * sg,\n 0.0331942 * sr + 0.9668058 * sg,\n 0.0170608 * sr + 0.072374 * sg + 0.9105652 * sb,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the Display-P3 gamut. */\nexport function inP3Gamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearP3(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** Largest in-gamut chroma for (l, h) under the given gamut test, via binary search. */\nfunction maxChromaFor(l: number, h: number, test: (l: number, c: number, h: number) => boolean): number {\n let lo = 0;\n let hi = MAX_CHROMA;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (test(l, mid, h)) lo = mid;\n else hi = mid;\n }\n // Sit 3% inside the boundary. A color exactly at the gamut edge renders fine in Chrome (CSS Color 4\n // hue-preserving gamut mapping) but Safari per-channel-clamps it toward GREY — so edge-chroma\n // foregrounds/icons went grey in Safari while staying tinted in Chrome. This margin keeps every emitted\n // color clearly in-gamut, so both engines show the same tint.\n return lo * 0.97;\n}\n\n/** Largest chroma keeping (l, h) inside the sRGB gamut. */\nexport function maxSrgbChroma(l: number, h: number): number {\n return maxChromaFor(l, h, inSrgbGamut);\n}\n\n/** Largest chroma keeping (l, h) inside the Display-P3 gamut. */\nexport function maxP3Chroma(l: number, h: number): number {\n return maxChromaFor(l, h, inP3Gamut);\n}\n\n/** Reduce a color's chroma until it fits the given gamut (lightness + hue preserved). */\nexport function clampToGamut(color: OklchColor, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n const max = gamut === \"p3\" ? maxP3Chroma(color.l, color.h) : maxSrgbChroma(color.l, color.h);\n return {\n ...color,\n c: Math.min(color.c, max),\n };\n}\n\nexport interface TonalScaleOptions {\n /** Fixed hue, 0–360. */\n hue: number;\n /** Number of steps. Default 12. */\n steps?: number;\n /** Lightness (0–1) at the lightest step. Default 0.98. */\n lightest?: number;\n /** Lightness (0–1) at the darkest step. Default 0.35. */\n darkest?: number;\n /** Peak chroma intent — the scale builds toward this (gamut permitting). Default 0.2. */\n chroma?: number;\n /** Easing exponent for the lightness descent (>1 stays light longer). Default 2.2. */\n lightEase?: number;\n /** Easing exponent for the chroma rise (>1 stays subtle longer). Default 1.1. */\n chromaRise?: number;\n /** Fraction of the in-gamut chroma ceiling to use, for headroom. Default 0.94. */\n gamutFraction?: number;\n /** Cap chroma to a gamut envelope (gives the natural peak + dark falloff). `\"p3\"` allows a\n * punchier peak on wide-gamut displays; `false` disables clamping. Default `\"srgb\"`. */\n gamut?: \"srgb\" | \"p3\" | false;\n}\n\n/**\n * Generate a tonal color scale — a single hue with lightness eased light→dark and chroma that\n * rises with the scale but is capped by the chosen gamut (sRGB by default; pass `gamut: \"p3\"` for a\n * punchier wide-gamut peak). Returns lightest → darkest as OklchColor[].\n * `tonalScaleColors({ hue: 252 })` gives a Radix/Tailwind-shaped 12-step blue scale.\n */\nexport function tonalScaleColors(options: TonalScaleOptions): OklchColor[] {\n const {\n hue,\n steps = 12,\n lightest = 0.98,\n darkest = 0.35,\n chroma = 0.2,\n lightEase = 2.2,\n chromaRise = 1.1,\n gamutFraction = 0.94,\n gamut = \"srgb\",\n } = options;\n\n const ceiling = gamut === \"p3\" ? maxP3Chroma : maxSrgbChroma;\n const n = Math.max(2, Math.round(steps));\n const out: OklchColor[] = [];\n for (let i = 0; i < n; i++) {\n const p = i / (n - 1);\n const l = (lightest - (lightest - darkest) * p ** lightEase) * 100;\n let c = chroma * p ** chromaRise;\n if (gamut) c = Math.min(c, ceiling(l, hue) * gamutFraction);\n out.push({\n l,\n c: clampChroma(c),\n h: hue,\n });\n }\n return out;\n}\n\n/** Which ramp drives a {@link rampGradient}. */\nexport type RampGradientAxis = \"hue\" | \"lightness\" | \"tonal\" | \"chroma\";\n\n/** Gradient painting shape — same color-stop list, different geometry. */\nexport type GradientShape = \"linear\" | \"radial\" | \"conic\";\n\n/** Geometry knobs for {@link wrapGradient}: `angle` (linear direction / conic `from` rotation), `position`\n * (radial + conic center, e.g. `\"50% 50%\"`, `\"top left\"`), and the radial `shape` / `size`. All optional;\n * the defaults reproduce a centered circle / 90° linear (so omitting them is a no-op). */\nexport interface GradientGeometry {\n angle?: number;\n position?: string;\n radialShape?: \"circle\" | \"ellipse\";\n radialSize?: \"closest-side\" | \"closest-corner\" | \"farthest-side\" | \"farthest-corner\";\n}\n\n/** Wrap a `` in the chosen gradient function (all interpolate `in oklch`). Linear uses the\n * angle as its direction; conic as the `from` rotation about `position`; radial paints a `shape`+`size`\n * centered at `position` (angle unused). */\nexport function wrapGradient(shape: GradientShape, stops: string, geom: GradientGeometry = {}): string {\n const { angle = 90, position = \"50% 50%\", radialShape = \"circle\", radialSize = \"farthest-corner\" } = geom;\n switch (shape) {\n case \"radial\":\n return `radial-gradient(${radialShape} ${radialSize} at ${position} in oklch, ${stops})`;\n case \"conic\":\n return `conic-gradient(from ${angle}deg at ${position} in oklch, ${stops})`;\n default:\n return `linear-gradient(${angle}deg in oklch, ${stops})`;\n }\n}\n\n/**\n * Build a CSS `linear-gradient` from one of the ramps, the seed centered as a slightly wider plateau\n * so the theme color anchors the middle; the rest sit in equal-width bands left → right. `count`\n * steps each side (clamped [3,8]). Interpolated `in oklch` for a perceptual blend.\n * e.g. rampGradient(\"tonal\", { l: 62, c: 0.15, h: 250 }, 5).\n */\n/**\n * THE one axis → colors mapping every backdrop shares (CSS gradient background, canvas background,\n * docs demos), seed-centered with `count` steps per side. Single-sourced so an axis can't mean\n * different math in different engines: `tonal` is the seed's own lightness ramp, gamut-clamped\n * (theme-true — the pure-CSS gradient's original definition), NOT the fixed-chroma tonal scale.\n */\nexport function rampAxisColors(axis: RampGradientAxis, seed: OklchColor, count: number, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n switch (axis) {\n case \"hue\":\n return hueRampColors(seed, count);\n case \"lightness\":\n return lightnessRampColors(seed, count);\n case \"chroma\":\n return chromaRampColors(seed, count, gamut === \"p3\" ? maxP3Chroma(seed.l, seed.h) : maxSrgbChroma(seed.l, seed.h));\n default:\n return lightnessRampColors(seed, count).map((color) => clampToGamut(color, gamut));\n }\n}\n\n/**\n * The lightness range a full-bleed BACKDROP may use while text over it stays readable.\n *\n * The ramps below deliberately cover their whole range — that is what makes them useful as swatches.\n * As a wallpaper it is the opposite of what you want: a lightness ramp runs L 0 → 100, so a single\n * solved foreground is legible at one end of the viewport and invisible at the other. This returns\n * the sub-range on the far side of `fg` where |Lc| stays at or above `targetLc`, measured with the\n * same APCA implementation the rest of the theme uses.\n *\n * Default target is 60 — APCA's non-body level. Body copy sits on veiled glass ABOVE the backdrop and\n * clears 75 comfortably from there; 60 is for the headings that sit directly on it. Asking 75 of the\n * raw backdrop would squeeze light mode into L 87–100 and leave nothing to make a gradient out of.\n *\n * |Lc| is monotonic in backdrop lightness on either side of a foreground, so this binary-searches the\n * boundary rather than sweeping.\n */\nexport function readableLightnessBand(\n fg: OklchColor | string,\n seed: OklchColor,\n targetLc = 60,\n headroom = 7,\n): {\n lMin: number;\n lMax: number;\n} {\n const text = toColor(fg);\n const passes = (l: number) =>\n Math.abs(\n apcaContrast(text, {\n ...seed,\n l,\n }),\n ) >= targetLc;\n /* A dark foreground pushes the backdrop light, and vice versa. */\n const backdropGoesLight = text.l < 50;\n let lo = backdropGoesLight ? 0 : 100;\n let hi = backdropGoesLight ? 100 : 0;\n if (!passes(hi)) {\n /* Nothing on this side clears the target (a mid-lightness foreground). Give back the whole\n range rather than an empty one — banding is a readability aid, not a hard gate. */\n return {\n lMin: 0,\n lMax: 100,\n };\n }\n for (let i = 0; i < 12; i++) {\n const mid = (lo + hi) / 2;\n if (passes(mid)) hi = mid;\n else lo = mid;\n }\n /* Stop `headroom` short of the absolute extreme. That end is the HIGH-contrast one, so backing off\n it costs no readability — but L 0 and L 100 are the two lightnesses that hold no chroma at all, so\n running to them washes the tint out of one edge of the wallpaper. Never let it cross the boundary. */\n const edge = Math.ceil(hi);\n return backdropGoesLight\n ? {\n lMin: edge,\n lMax: Math.max(edge + 1, 100 - headroom),\n }\n : {\n lMin: Math.min(Math.floor(hi) - 1, headroom),\n lMax: Math.floor(hi),\n };\n}\n\n/** Remap a ramp's lightnesses into `band`, preserving the ramp's shape. A ramp whose lightness is\n * constant (the hue and chroma axes) is clamped into the band instead of stretched across it.\n * Exported so the canvas backdrop bands the same ramps the CSS gradient does. */\nexport function fitToBand(\n colors: OklchColor[],\n band: {\n lMin: number;\n lMax: number;\n },\n): OklchColor[] {\n const ls = colors.map((c) => c.l);\n const lo = Math.min(...ls);\n const hi = Math.max(...ls);\n const span = hi - lo;\n if (span < 0.001) {\n const l = Math.min(band.lMax, Math.max(band.lMin, lo));\n return colors.map((c) => ({\n ...c,\n l,\n }));\n }\n return colors.map((c) => ({\n ...c,\n l: band.lMin + ((c.l - lo) / span) * (band.lMax - band.lMin),\n }));\n}\n\n/* Frescoes are authored as fixed multi-hue stops at one lightness, so they skip the ramp entirely —\n which also means they skip its banding and its conic loop-close. Apply both here. */\nexport function bandedFrescoStops(\n colors: string[],\n band:\n | {\n lMin: number;\n lMax: number;\n }\n | undefined,\n shape: GradientShape,\n): string {\n let out = colors;\n if (band) {\n out = out.map((css) => {\n const parsed = parseOklch(css);\n if (!parsed) return css;\n return formatOklch({\n ...parsed,\n l: Math.min(band.lMax, Math.max(band.lMin, parsed.l)),\n });\n });\n }\n if (shape === \"conic\" && out.length > 1) {\n const loop = [\n ...out,\n out[0],\n ];\n return loop.map((css, i) => `${css} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`).join(\", \");\n }\n return out.join(\", \");\n}\n\nexport function rampGradient(\n axis: RampGradientAxis,\n seed: OklchColor,\n count: number,\n options: {\n gamut?: \"srgb\" | \"p3\";\n shape?: GradientShape;\n /** Constrain the ramp's lightness to a readable range — see {@link readableLightnessBand}. */\n band?: {\n lMin: number;\n lMax: number;\n };\n } & GradientGeometry = {},\n): string {\n const { gamut = \"srgb\", shape = \"linear\", band, angle, position, radialShape, radialSize } = options;\n const ramped = rampAxisColors(axis, seed, count, gamut);\n const colors = band ? fitToBand(ramped, band) : ramped;\n\n /* A conic gradient wraps: whatever sits at 360° butts straight into 0°. An open ramp therefore\n meets its own opposite end at the twelve-o'clock line and draws a hard seam right through the\n wallpaper — at full range that is white against black. Close the loop instead, and drop the\n center plateau, which reads as a lopsided wedge once the ramp is bent into a circle.\n hue — already cyclic, so repeating the first color at 360° spaces evenly and joins invisibly.\n other — mirror out and back, giving a symmetric sweep that also ends where it started. */\n if (shape === \"conic\") {\n const loop =\n axis === \"hue\"\n ? [\n ...colors,\n colors[0],\n ]\n : [\n ...colors,\n ...colors.slice(0, -1).reverse(),\n ];\n const stops = loop.map((color, i) => `${formatOklch(color)} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`);\n return wrapGradient(shape, stops.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n }\n\n const mid = Math.floor(colors.length / 2);\n const plateau = 7; // half-width (%) of the centered theme-color band\n const leftEnd = 50 - plateau;\n const rightStart = 50 + plateau;\n const parts = colors.map((color, i) => {\n const css = formatOklch(color);\n if (i === mid) return `${css} ${leftEnd}%, ${css} ${rightStart}%`;\n const pos = i < mid ? (i / mid) * leftEnd : rightStart + ((i - mid) / mid) * (100 - rightStart);\n return `${css} ${pos.toFixed(1)}%`;\n });\n return wrapGradient(shape, parts.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n}\n\n// ── APCA contrast (APCA-W3 / ARC) ─────────────────────────────────────────────\n// Inlined port of the APCA-W3 reference (© Andrew Somers / Myndex Research, licensed to the W3/AGWG).\n// The APCA constants below are the LOCKED beta set (0.1.x) and are used UNMODIFIED. APCA/ARC measures\n// readability contrast — it is NOT a means to claim WCAG 2 conformance. Verified to match apca-w3's\n// APCAcontrast() — see scripts/apca-oracle.mjs. Lc is signed: positive = dark text on a light\n// background, negative = light text on a dark one; |Lc| is the perceptual level (~45 large/UI, ~60\n// other content, ~75 body, ~90 fine). Polarity-aware — which WCAG-2's ratio is not — so it fits dark\n// mode + tinted glass far better.\n\n/** OKLCH (l 0–100) → gamma-encoded sRGB channels in [0, 1] (out-of-gamut values are clipped). */\nexport function oklchToSrgb(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n const [lr, lg, lb] = oklchToLinearSrgb(l, c, h);\n const encode = (v: number) => {\n const x = Math.max(0, Math.min(1, v));\n return x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055;\n };\n return [\n encode(lr),\n encode(lg),\n encode(lb),\n ];\n}\n\n/** APCA screen luminance (Ys) from gamma-encoded sRGB. */\nfunction apcaLuminance([r, g, b]: [\n number,\n number,\n number,\n]): number {\n return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;\n}\n\n/**\n * APCA lightness contrast (Lc) between a text color and a background color. Signed: positive = dark\n * text on a light bg, negative = light text on a dark bg; use |Lc| for the level (~60 headings,\n * ~75 body text). A dependency-free port of APCA-W3 0.1.9.\n */\nexport function apcaContrast(text: OklchColor | string, bg: OklchColor | string): number {\n const t = toColor(text);\n const b = toColor(bg);\n let txtY = apcaLuminance(oklchToSrgb(t.l, t.c, t.h));\n let bgY = apcaLuminance(oklchToSrgb(b.l, b.c, b.h));\n const blkThrs = 0.022;\n // biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA blkClmp tuning constant, not √2\n const blkClmp = 1.414;\n txtY = txtY > blkThrs ? txtY : txtY + (blkThrs - txtY) ** blkClmp;\n bgY = bgY > blkThrs ? bgY : bgY + (blkThrs - bgY) ** blkClmp;\n if (Math.abs(bgY - txtY) < 0.0005) return 0;\n let lc: number;\n if (bgY > txtY) {\n const sapc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;\n lc = sapc < 0.1 ? 0 : sapc - 0.027;\n } else {\n const sapc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;\n lc = sapc > -0.1 ? 0 : sapc + 0.027;\n }\n return lc * 100;\n}\n\nconst FG_LIGHT: OklchColor = {\n l: 100,\n c: 0,\n h: 0,\n};\nconst FG_DARK: OklchColor = {\n l: 15,\n c: 0,\n h: 0,\n};\n\n/**\n * Pick whichever foreground has the higher APCA contrast on `bg` — by default near-white vs the\n * near-black `--foreground`. The light, perceptual way to choose a readable text/icon color: cheap\n * enough to run on a theme/tint change (microseconds), no per-frame work, no canvas readback.\n */\nexport function pickForeground(bg: OklchColor | string, light: OklchColor = FG_LIGHT, dark: OklchColor = FG_DARK): OklchColor {\n return Math.abs(apcaContrast(light, bg)) >= Math.abs(apcaContrast(dark, bg)) ? light : dark;\n}\n\n/**\n * From `ramp`, prefer colors whose |APCA Lc| on `surface` falls within [floor, ceiling] and sit closest\n * to `target` — so the floor is honored as a MINIMUM (never undershot) and the ceiling caps the spike.\n * Draws a readable foreground from a real palette (a tonal / lightness ramp) instead of a neutral gray,\n * so text keeps the theme's color while hitting its band. If nothing lands in band (ramp too coarse or\n * the surface can't support it), returns the color nearest the band edge.\n */\nexport function pickInBand(\n ramp: OklchColor[],\n surface: OklchColor | string,\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n): OklchColor {\n const scored = ramp.map((c) => ({\n c,\n lc: Math.abs(apcaContrast(c, surface)),\n }));\n const inBand = scored.filter((s) => s.lc >= band.floor && s.lc <= band.ceiling);\n const pool = inBand.length ? inBand : scored;\n const err = (lc: number) => (inBand.length ? Math.abs(lc - band.target) : Math.min(Math.abs(lc - band.floor), Math.abs(lc - band.ceiling)));\n return pool.reduce((best, s) => (err(s.lc) < err(best.lc) ? s : best), pool[0]).c;\n}\n\n/**\n * The effective glass surface color for the active theme + tint — the theme's light/dark floor\n * blended with the tint wash, mirroring the glass-* utilities (the wash sits at a FIXED lightness,\n * 72 light / 58 dark; only hue, chroma and alpha vary). Pair with pickForeground to choose readable\n * text on a tinted glass surface without reading pixels back.\n */\nexport function glassSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const washL = dark ? 58 : 72;\n return {\n l: baseL * (1 - tint.a) + washL * tint.a,\n c: tint.c * 2.5 * tint.a,\n h: tint.h,\n };\n}\n\n/**\n * The effective glass-SOLID surface color — the legible floor body text actually sits on (never sheer\n * glass). The solid floor (neutral `--glass-solid-l`, 99 light / 18 dark) is composited over the base\n * at `solidA` (the `--glass-solid-a` opacity, ~0.3–0.75), then the tint wash on top. Because that floor\n * is a KNOWN surface, banding text against this — rather than the sheer estimate — gives a real Lc.\n */\nexport function glassSolidSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n solidA: number,\n // Wash knobs — pass the LIVE --glass-wash-l / --glass-wash-c-mult when a theme overrides them\n // (moonstone night: 72 / 2), else the defaults mirror the CSS mode values exactly.\n washL: number = dark ? 58 : 72,\n washCMult = 2.5,\n // The SOLIDIFY floor — `glass` paints --glass-solidify (--glass-opaque-bg at --glass-opacity, default\n // 0.7) as its bottom background-IMAGE layer, i.e. above the background-color modeled by solidA and\n // below the wash. Omitting it models a surface far sheerer than what ships: on most themes the opaque\n // floor sits the same side of mid-grey as the page, so the error is a few Lc, but where the two\n // OPPOSE — moonstone night pairs a cream L84.9 floor with an L20 page — the model lands 27 L on the\n // wrong side and picks text of the wrong polarity entirely. Pass { l, c, a } to include it.\n solidify?: {\n l: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const solidL = dark ? 18 : 99;\n let floorL = baseL * (1 - solidA) + solidL * solidA;\n let floorC = 0;\n if (solidify) {\n floorL = floorL * (1 - solidify.a) + solidify.l * solidify.a;\n floorC = solidify.c * solidify.a;\n }\n return {\n l: floorL * (1 - tint.a) + washL * tint.a,\n // The wash composites OVER the solidified floor, so the floor's own chroma survives in proportion\n // to what the wash lets through — same mix the lightness above takes.\n c: floorC * (1 - tint.a) + tint.c * washCMult * tint.a,\n h: tint.h,\n };\n}\n\n/** Options for {@link themeForeground}. */\nexport interface ThemeForegroundOptions {\n /** Which of the ramp generator's axes the text levels follow. */\n palette: \"tonal\" | \"lightness\" | \"hue\" | \"chroma\";\n /** Text level: 0 = the first swatch; each +1 is one step along the ramp. */\n level: number;\n /** Total steps (the ramp generator's count). */\n count: number;\n /** The chosen color the ramp is built from (the ramp generator's base). */\n base: {\n l: number;\n c: number;\n h: number;\n };\n /** Dark theme? Picks which lightness extreme the tonal/lightness ramps start from. */\n dark: boolean;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * Walk one of the ramp generator's ramps — built from the chosen `base` color — mapping ramp steps\n * to text levels. The ramp is **base-centered with `count` steps EITHER SIDE** (so it matches the\n * ramp generator's \"steps each side\"): level 0 = the readable extreme, level `count` = the base\n * (center), level `2·count` = the opposite extreme — `2·count + 1` levels total. The readable half\n * (levels 0..count) is unchanged, so the live --foreground / --muted-foreground cascade (which only\n * reads that half) is unaffected. `palette` picks the axis:\n * - tonal / lightness: level 0 is the readable lightness extreme (white in dark mode, black in\n * light), easing to the base's lightness at `count`, then on to the opposite extreme;\n * - hue: constant L + C — level 0 is base + count steps, ramping through the base and on the\n * other way (e.g. base `oklch(60% 0.15 255)`, count 8 → level 0 `oklch(60% 0.15 64.4)`);\n * - chroma: constant L + hue, chroma 0 → base → the gamut-displayable max.\n * Gamut-clamped.\n */\nexport function themeForeground(options: ThemeForegroundOptions): OklchColor {\n const { palette, level, count, base, dark, gamut = \"srgb\" } = options;\n // Piecewise around the centered base: readable extreme (level 0) → base (level count) → other.\n const span = (readable: number, mid: number, other: number) => {\n if (count <= 0) return mid;\n if (level <= count) return readable + (mid - readable) * (level / count);\n return mid + (other - mid) * Math.min(1, (level - count) / count);\n };\n const readableL = dark ? 100 : 0;\n const otherL = dark ? 0 : 100;\n const chromaCap = gamut === \"p3\" ? maxP3Chroma(base.l, base.h) : maxSrgbChroma(base.l, base.h);\n let color: OklchColor;\n switch (palette) {\n case \"hue\":\n color = {\n l: base.l,\n c: base.c,\n h: wrapHue(base.h + (count - level) * (360 / (2 * count + 1))),\n };\n break;\n case \"chroma\":\n color = {\n l: base.l,\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n break;\n case \"lightness\":\n color = {\n l: span(readableL, base.l, otherL),\n c: base.c,\n h: base.h,\n };\n break;\n default:\n color = {\n l: span(readableL, base.l, otherL),\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n }\n return clampToGamut(color, gamut);\n}\n\n// ── Readable foreground (soft contrast / ARC Bronze Simple Mode) ──────────────\n// pickForeground() maximizes contrast — it slams to the lightness extreme (pure white / near-black),\n// which reads as a glare/heaviness \"spike\". readableForeground() instead AIMS for a target APCA Lc\n// and stops: legible, but soft. The usage presets follow the ARC \"Bronze Simple Mode\" criterion\n// (readtech.org/ARC) — content-text thresholds, no font-lookup table: body Lc 75 (preferred 90),\n// other content Lc 60, large-fluent (>36px) Lc 45 with a MAX of Lc 90 to \"prevent excessive contrast\"\n// (so the ceiling is straight from the spec). floor = legible minimum, target = aim, ceiling = the cap.\n// `ui` / `non-text` / `disabled` are OUR extension — Bronze scopes to content text, excluding spot text.\n\n/** APCA-derived contrast bands per use case. floor = legible minimum, target = aim, ceiling = cap. */\nexport const READABLE_USAGE = {\n /** Fine / thin / small text — needs the most contrast; little soft room. */\n small: {\n floor: 90,\n target: 90,\n ceiling: 100,\n },\n /** Body text (~16px / 400) — ARC Bronze body: min 75, preferred 90. */\n body: {\n floor: 75,\n target: 80,\n ceiling: 90,\n },\n /** Large or fluent body (~18–24px) / semibold. */\n \"body-large\": {\n floor: 60,\n target: 72,\n ceiling: 88,\n },\n /** Headings / other content text — ARC Bronze \"other content\": min 60. */\n heading: {\n floor: 60,\n target: 66,\n ceiling: 82,\n },\n /** Large fluent text >36px (or ≥24px bold) — ARC Bronze: min 45, max 90. */\n large: {\n floor: 45,\n target: 58,\n ceiling: 76,\n },\n /** UI labels, icons, focus rings — beyond ARC Bronze scope (Bronze is content-text only). Target at the\n * APCA \"other content\" level (60); ceiling 70 stays below body's 75 floor, so icons read quieter than text. */\n ui: {\n floor: 45,\n target: 60,\n ceiling: 70,\n },\n /** Non-text: borders, dividers, graphics — beyond ARC Bronze scope. */\n \"non-text\": {\n floor: 30,\n target: 40,\n ceiling: 58,\n },\n /** Disabled / placeholder — beyond ARC Bronze scope (spot text). */\n disabled: {\n floor: 30,\n target: 33,\n ceiling: 46,\n },\n} as const;\n\nexport type ReadableUsage = keyof typeof READABLE_USAGE;\n\n/** Options for {@link readableForeground}. */\nexport interface ReadableForegroundOptions {\n /** Use-case preset that sets the {floor, target, ceiling} band (default \"body\"). */\n usage?: ReadableUsage;\n /** Aim for this |APCA Lc| — overrides the usage preset's target. */\n target?: number;\n /** Never exceed this |Lc| — the anti-spike cap; overrides the usage preset. */\n ceiling?: number;\n /** Legibility minimum; if even the extreme can't reach it you get max contrast (add a scrim). */\n floor?: number;\n /** Hue to keep for tinted-but-readable text. Defaults to the background's hue. */\n hue?: number;\n /** Chroma to keep, gamut-clamped at the chosen lightness. 0 = neutral gray. */\n chroma?: number;\n /** Opt-in chroma floor (capped at `chroma`). When the band pins the pick to the lightness EXTREME\n * (target/floor beyond the surface's reach), the gamut annihilates the requested chroma there and\n * tinted text reads pure black/white; this backs the aim off to the most contrast available at a\n * lightness that still HOLDS this much chroma — never below an ACHIEVABLE band floor. Reachable\n * bands are unaffected. 0 (default) = off. */\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * A foreground that AIMS for a target APCA contrast on `bg` instead of maximizing it — legible\n * without the harsh pure-black / pure-white spike. Picks the lightness direction with headroom and\n * binary-searches the NEAREST lightness whose |Lc| meets the target (capped at the ceiling); keeps an\n * optional hue/chroma for tinted-but-readable text. The soft-contrast companion to {@link pickForeground}.\n * If even the lightness extreme can't reach the floor (a mid-gray background), it returns the maximum\n * contrast available — the signal that the text needs a scrim / solid backing.\n */\nexport function readableForeground(bg: OklchColor | string, opts: ReadableForegroundOptions = {}): OklchColor {\n const band = READABLE_USAGE[opts.usage ?? \"body\"];\n const target = opts.target ?? band.target;\n const ceiling = opts.ceiling ?? band.ceiling;\n const floor = opts.floor ?? band.floor;\n const { chroma = 0, minChroma = 0, gamut = \"srgb\" } = opts;\n\n const b = toColor(bg);\n const h = opts.hue ?? b.h;\n const cAt = (l: number) => Math.min(chroma, gamut === \"p3\" ? maxP3Chroma(l, h) : maxSrgbChroma(l, h));\n const lcAt = (l: number) =>\n Math.abs(\n apcaContrast(\n {\n l,\n c: cAt(l),\n h,\n },\n b,\n ),\n );\n\n const extreme = lcAt(0) >= lcAt(100) ? 0 : 100; // the lightness direction with headroom\n const reach = lcAt(extreme); // the most contrast this background can give\n // Aim for the target, never past the ceiling; if we can't even clear the floor, take the max.\n let want = reach < floor ? reach : Math.min(target, ceiling, reach);\n\n // Chroma preservation (opt-in, minChroma > 0): want === reach means the band sits beyond what this\n // surface can give, so the search below would pin to the lightness EXTREME — where the gamut\n // annihilates the requested chroma and tinted text reads pure black/white (moonstone-dark cream: reach\n // ≈ 68 < body floor 75 → L≈0 → a chosen accent renders BLACK). Trade the few unreachable Lc points\n // for color: aim instead at the most contrast available at a lightness that still HOLDS the kept\n // chroma — never below the band floor when the floor is achievable. In-reach bands never get here.\n const keep = Math.min(minChroma, chroma);\n if (keep > 0 && want >= reach) {\n // Strict in-gamut test at the kept chroma (÷0.97 so the margined cAt still returns ≥ keep). The\n // shared eps-tolerant gamut test reports PHANTOM chroma at L≈0 — cubed near-zero channels sit\n // inside its 1e-4 tolerance (up to ~0.11 at red/pink hues) — which would converge this search\n // right back to the extreme; eps 1e-6 rejects it.\n const holdsKeep = (l: number) => {\n const [r, g, b3] = gamut === \"p3\" ? oklchToLinearP3(l, keep / 0.97, h) : oklchToLinearSrgb(l, keep / 0.97, h);\n const eps = 1e-6;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b3 >= -eps && b3 <= 1 + eps;\n };\n // Max chroma is ~unimodal in lightness and 0 at both extremes: holdsKeep is false at the extreme\n // and (guarded) true at bg.l, so bisect for the holding lightness NEAREST the extreme.\n if (holdsKeep(b.l)) {\n let kLo = extreme;\n let kHi = b.l;\n for (let i = 0; i < 24; i++) {\n const mid = (kLo + kHi) / 2;\n if (holdsKeep(mid)) kHi = mid;\n else kLo = mid;\n }\n want = Math.min(want, Math.max(lcAt(kHi), reach >= floor ? floor : 0));\n }\n }\n\n // |Lc| rises monotonically from bg.l → the extreme, so binary-search the nearest L meeting `want`.\n let lo = b.l;\n let hi = extreme;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (lcAt(mid) < want) lo = mid;\n else hi = mid;\n }\n return clampToGamut(\n {\n l: hi,\n c: cAt(hi),\n h,\n },\n gamut,\n );\n}\n", + "content": "/**\n * OKLCH color primitives — parse / format an oklch string and generate symmetric ramps\n * (hue or chroma) around an existing color. Dependency-free; shares the {l, c, h} shape with\n * `GradientColor` in gradient-utils.ts.\n */\n\nexport interface OklchColor {\n /** Lightness, 0–100 (%) */\n l: number;\n /** Chroma, 0–~0.37 */\n c: number;\n /** Hue, 0–360 (degrees) */\n h: number;\n /** Optional alpha, 0–1 */\n alpha?: number;\n}\n\n/**\n * Practical oklch chroma ceiling. Both sRGB and Display-P3 colors stay below ~0.37 (per Evil\n * Martians; confirmed by sweeping every L×hue with the gamut math below — sRGB max ≈ 0.321,\n * P3 max ≈ 0.363). Used as the binary-search bound and the chroma-ramp default range.\n */\nexport const MAX_CHROMA = 0.37;\n\n/** Wrap a hue into [0, 360). */\nexport function wrapHue(h: number): number {\n return ((h % 360) + 360) % 360;\n}\n\n/** Clamp chroma into [0, MAX_CHROMA]. */\nexport function clampChroma(c: number): number {\n return Math.max(0, Math.min(MAX_CHROMA, c));\n}\n\n/** A bare lightness ≤ 1 is the 0–1 form (×100); otherwise it's already 0–100. */\nfunction normalizeLightness(l: number): number {\n return l <= 1 ? l * 100 : l;\n}\n\n/**\n * Parse an `oklch()` string into components. Handles both lightness conventions used in this\n * repo — `oklch(72% 0.05 255)` and `oklch(0.72 0.05 255)` — plus an optional `/ alpha`.\n * Returns null if the string isn't a parseable oklch().\n */\nexport function parseOklch(input: string): OklchColor | null {\n const match = /^\\s*oklch\\(([^)]+)\\)\\s*$/i.exec(input);\n if (!match) return null;\n\n const [coords, alphaPart] = match[1].split(\"/\");\n const parts = coords.trim().split(/\\s+/);\n if (parts.length < 3) return null;\n\n const lRaw = parts[0];\n const l = lRaw.endsWith(\"%\") ? Number.parseFloat(lRaw) : normalizeLightness(Number.parseFloat(lRaw));\n const c = Number.parseFloat(parts[1]);\n const h = Number.parseFloat(parts[2]);\n if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) return null;\n\n const color: OklchColor = {\n l,\n c,\n h,\n };\n if (alphaPart !== undefined) {\n const a = Number.parseFloat(alphaPart.trim());\n if (Number.isFinite(a)) color.alpha = a;\n }\n return color;\n}\n\n/** Format components back into an `oklch()` string (lightness as %). */\nexport function formatOklch(color: OklchColor, alpha?: number): string {\n const a = alpha ?? color.alpha;\n const base = `${color.l.toFixed(1)}% ${color.c.toFixed(3)} ${color.h.toFixed(1)}`;\n return a === undefined ? `oklch(${base})` : `oklch(${base} / ${a})`;\n}\n\n/** Coerce a base argument (string | OklchColor) into components; throws on an unparseable string. */\nfunction toColor(base: OklchColor | string): OklchColor {\n if (typeof base !== \"string\") return base;\n const parsed = parseOklch(base);\n if (!parsed) throw new Error(`oklch-utils: could not parse \"${base}\"`);\n return parsed;\n}\n\n/** Per-side step count, clamped to a sane [3, 12] (rounded). */\nfunction clampCount(count: number): number {\n return Math.max(3, Math.min(12, Math.round(count)));\n}\n\n/**\n * A full-range value ramp: `count` steps each side of `seed`, reaching `min` on the left and\n * `max` on the right, with the seed held at the center index. Step sizes differ per side (the\n * seed is rarely the midpoint), so the whole [min, max] range is always covered — more steps\n * just sample it finer. Returns `2 * count + 1` values, min → seed → max.\n */\nfunction rangeRamp(seed: number, min: number, max: number, count: number): number[] {\n const leftStep = (seed - min) / count;\n const rightStep = (max - seed) / count;\n const out: number[] = [];\n for (let i = count; i >= 1; i--) out.push(seed - leftStep * i);\n out.push(seed);\n for (let i = 1; i <= count; i++) out.push(seed + rightStep * i);\n return out;\n}\n\n/**\n * Hue ramp covering the FULL wheel: `count` steps each side, the seed centered, hues spread evenly\n * around the wheel (step = 360 / (2·count + 1)) and wrapped into [0, 360). Lightness + chroma are\n * held; `count` clamped to [3, 12]. Unlike chroma/lightness, hue is cyclic — 0° ≡ 360° — so the ramp\n * is distributed cyclically instead of running to both endpoints, which keeps the two edge swatches\n * distinct. e.g. seed 120, count 4 → 320, 0, 40, 80, 120, 160, 200, 240, 280.\n */\nexport function hueRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n const n = clampCount(count);\n const seed = wrapHue(color.h);\n const step = 360 / (2 * n + 1);\n const out: OklchColor[] = [];\n for (let k = -n; k <= n; k++) {\n out.push({\n ...color,\n h: wrapHue(seed + k * step),\n });\n }\n return out;\n}\n\n/** Full-wheel hue ramp as CSS oklch strings (the seed sits at the center index). */\nexport function hueRamp(base: OklchColor | string, count: number): string[] {\n return hueRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Hue harmonies ─────────────────────────────────────────────────────────────\n// Rotate the base hue to derive complementary / harmonious colors (lightness + chroma held). oklch\n// hue is perceptually even, so a 180° \"complement\" is a balanced opposite — not the skewed HSL/RGB one.\n\n/** The perceptual complement: same L + C, hue rotated 180° (alpha preserved), gamut-clamped — the\n * 180° case of {@link harmony}. */\nexport function complement(base: OklchColor | string, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n return harmony(\n base,\n [\n 180,\n ],\n gamut,\n )[1];\n}\n\n/**\n * Hue-harmony set: the base plus a color at each rotation `angle` in degrees (same L + C),\n * gamut-clamped. Returns `[base, …rotations]`. Common sets — analogous `[-30, 30]`, triadic\n * `[120, 240]`, split-complement `[150, 210]`, tetradic `[90, 180, 270]`.\n */\nexport function harmony(base: OklchColor | string, angles: number[], gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n const c = toColor(base);\n return [\n clampToGamut(c, gamut),\n ...angles.map((a) =>\n clampToGamut(\n {\n ...c,\n h: wrapHue(c.h + a),\n },\n gamut,\n ),\n ),\n ];\n}\n\n/** Named color-wheel relationships — the JS mirror of the --hue-* tokens in app/globals.css. */\nexport type HarmonicName =\n | \"base\"\n | \"complement\"\n | \"analogous-1\"\n | \"analogous-2\"\n | \"split-1\"\n | \"split-2\"\n | \"triad-1\"\n | \"triad-2\"\n | \"tetrad-1\"\n | \"tetrad-2\"\n | \"tetrad-3\"\n | \"square-1\"\n | \"square-2\"\n | \"square-3\";\n\n/**\n * Hue offsets (degrees) for each color-wheel relationship. MUST stay in lockstep with the `--hue-*`\n * tokens in app/globals.css (`calc(var(--harmony-h) + N)`) so a JS-solved icon/foreground hue lands on\n * the SAME angle as the CSS harmonic swatches. Pair with `harmonicHue()` + `readableForeground()` to get\n * a contrast-solved (APCA/ARC) color at the harmonic angle.\n */\nexport const HARMONIC_OFFSETS: Record = {\n base: 0,\n complement: 180,\n \"analogous-1\": -30,\n \"analogous-2\": 30,\n \"split-1\": 150,\n \"split-2\": 210,\n \"triad-1\": 120,\n \"triad-2\": 240,\n \"tetrad-1\": 60,\n \"tetrad-2\": 180,\n \"tetrad-3\": 240,\n \"square-1\": 90,\n \"square-2\": 180,\n \"square-3\": 270,\n};\n\n/** All harmonic relationship names, in wheel order (for building pickers). */\nexport const HARMONIC_NAMES = Object.keys(HARMONIC_OFFSETS) as HarmonicName[];\n\n/**\n * Rotate an anchor hue by a named color-wheel relationship, wrapped to [0, 360). The anchor is normally\n * the harmony hue (--harmony-h: the content hue, or 0 for the hue-less selenite/moonstone themes). Feed the\n * result to `readableForeground({ hue })` to keep the contrast solve while landing on the harmonic angle.\n */\nexport function harmonicHue(anchorH: number, name: HarmonicName): number {\n return wrapHue(anchorH + HARMONIC_OFFSETS[name]);\n}\n\n/**\n * Chroma ramp covering [0, cap]: `count` steps each side, the seed centered (left → 0, right →\n * cap). Lightness + hue are held; `count` clamped to [3, 12]. `max` defaults to the largest chroma\n * actually DISPLAYABLE for this L+hue in sRGB (≈0.32, varies by L/hue) — so the ramp reaches the\n * visible edge instead of a flat MAX_CHROMA that just clamps on screen. Pass `maxP3Chroma(l, h)` on\n * P3-capable displays for a punchier top end, or any explicit number to sweep a custom range.\n */\nexport function chromaRampColors(base: OklchColor | string, count: number, max?: number): OklchColor[] {\n const color = toColor(base);\n const cap = max ?? maxSrgbChroma(color.l, color.h);\n const seed = Math.max(0, Math.min(cap, color.c));\n return rangeRamp(seed, 0, cap, clampCount(count)).map((c) => ({\n ...color,\n c,\n }));\n}\n\n/** Chroma ramp as CSS oklch strings (the seed sits at the center index). */\nexport function chromaRamp(base: OklchColor | string, count: number, max?: number): string[] {\n return chromaRampColors(base, count, max).map((color) => formatOklch(color));\n}\n\n/**\n * Lightness ramp covering the FULL range [0, 100]: `count` steps each side, the seed centered\n * (left → 0, right → 100). Chroma + hue are held; `count` clamped to [3, 12].\n */\nexport function lightnessRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n return rangeRamp(color.l, 0, 100, clampCount(count)).map((l) => ({\n ...color,\n l,\n }));\n}\n\n/** Full-range lightness ramp as CSS oklch strings (the seed sits at the center index). */\nexport function lightnessRamp(base: OklchColor | string, count: number): string[] {\n return lightnessRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Gamut-aware tonal scale ───────────────────────────────────────────────────\n// A tonal scale (the Radix/Tailwind 1→N pattern) is NOT a chroma shift: lightness eases\n// light→dark while chroma rises with the scale but is capped by the sRGB gamut — so chroma is\n// near-zero in the light tints, peaks in the mid-dark \"brand\" steps, then tapers in the darkest\n// steps (because dark colors can't physically hold as much chroma). The gamut cap is what\n// produces that peak-then-fall curve; a flat chroma shift cannot.\n\n/** OKLCH (l 0–100) → linear sRGB channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearSrgb(\n l: number,\n c: number,\n hDeg: number,\n): [\n number,\n number,\n number,\n] {\n const L = l / 100;\n const hr = (hDeg * Math.PI) / 180;\n const a = c * Math.cos(hr);\n const b = c * Math.sin(hr);\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.291485548 * b;\n const ll = l_ * l_ * l_;\n const mm = m_ * m_ * m_;\n const ss = s_ * s_ * s_;\n return [\n 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss,\n -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss,\n -0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the sRGB gamut. */\nexport function inSrgbGamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearSrgb(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** OKLCH (l 0–100) → linear Display-P3 channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearP3(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n // linear sRGB → linear Display-P3 (same color, wider basis) — valid even for out-of-sRGB values.\n const [sr, sg, sb] = oklchToLinearSrgb(l, c, h);\n return [\n 0.8224621 * sr + 0.177538 * sg,\n 0.0331942 * sr + 0.9668058 * sg,\n 0.0170608 * sr + 0.072374 * sg + 0.9105652 * sb,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the Display-P3 gamut. */\nexport function inP3Gamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearP3(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** Largest in-gamut chroma for (l, h) under the given gamut test, via binary search. */\nfunction maxChromaFor(l: number, h: number, test: (l: number, c: number, h: number) => boolean): number {\n let lo = 0;\n let hi = MAX_CHROMA;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (test(l, mid, h)) lo = mid;\n else hi = mid;\n }\n // Sit 3% inside the boundary. A color exactly at the gamut edge renders fine in Chrome (CSS Color 4\n // hue-preserving gamut mapping) but Safari per-channel-clamps it toward GREY — so edge-chroma\n // foregrounds/icons went grey in Safari while staying tinted in Chrome. This margin keeps every emitted\n // color clearly in-gamut, so both engines show the same tint.\n return lo * 0.97;\n}\n\n/** Largest chroma keeping (l, h) inside the sRGB gamut. */\nexport function maxSrgbChroma(l: number, h: number): number {\n return maxChromaFor(l, h, inSrgbGamut);\n}\n\n/** Largest chroma keeping (l, h) inside the Display-P3 gamut. */\nexport function maxP3Chroma(l: number, h: number): number {\n return maxChromaFor(l, h, inP3Gamut);\n}\n\n/** Reduce a color's chroma until it fits the given gamut (lightness + hue preserved). */\nexport function clampToGamut(color: OklchColor, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n const max = gamut === \"p3\" ? maxP3Chroma(color.l, color.h) : maxSrgbChroma(color.l, color.h);\n return {\n ...color,\n c: Math.min(color.c, max),\n };\n}\n\nexport interface TonalScaleOptions {\n /** Fixed hue, 0–360. */\n hue: number;\n /** Number of steps. Default 12. */\n steps?: number;\n /** Lightness (0–1) at the lightest step. Default 0.98. */\n lightest?: number;\n /** Lightness (0–1) at the darkest step. Default 0.35. */\n darkest?: number;\n /** Peak chroma intent — the scale builds toward this (gamut permitting). Default 0.2. */\n chroma?: number;\n /** Easing exponent for the lightness descent (>1 stays light longer). Default 2.2. */\n lightEase?: number;\n /** Easing exponent for the chroma rise (>1 stays subtle longer). Default 1.1. */\n chromaRise?: number;\n /** Fraction of the in-gamut chroma ceiling to use, for headroom. Default 0.94. */\n gamutFraction?: number;\n /** Cap chroma to a gamut envelope (gives the natural peak + dark falloff). `\"p3\"` allows a\n * punchier peak on wide-gamut displays; `false` disables clamping. Default `\"srgb\"`. */\n gamut?: \"srgb\" | \"p3\" | false;\n}\n\n/**\n * Generate a tonal color scale — a single hue with lightness eased light→dark and chroma that\n * rises with the scale but is capped by the chosen gamut (sRGB by default; pass `gamut: \"p3\"` for a\n * punchier wide-gamut peak). Returns lightest → darkest as OklchColor[].\n * `tonalScaleColors({ hue: 252 })` gives a Radix/Tailwind-shaped 12-step blue scale.\n */\nexport function tonalScaleColors(options: TonalScaleOptions): OklchColor[] {\n const {\n hue,\n steps = 12,\n lightest = 0.98,\n darkest = 0.35,\n chroma = 0.2,\n lightEase = 2.2,\n chromaRise = 1.1,\n gamutFraction = 0.94,\n gamut = \"srgb\",\n } = options;\n\n const ceiling = gamut === \"p3\" ? maxP3Chroma : maxSrgbChroma;\n const n = Math.max(2, Math.round(steps));\n const out: OklchColor[] = [];\n for (let i = 0; i < n; i++) {\n const p = i / (n - 1);\n const l = (lightest - (lightest - darkest) * p ** lightEase) * 100;\n let c = chroma * p ** chromaRise;\n if (gamut) c = Math.min(c, ceiling(l, hue) * gamutFraction);\n out.push({\n l,\n c: clampChroma(c),\n h: hue,\n });\n }\n return out;\n}\n\n/** Which ramp drives a {@link rampGradient}. */\nexport type RampGradientAxis = \"hue\" | \"lightness\" | \"tonal\" | \"chroma\";\n\n/** Gradient painting shape — same color-stop list, different geometry. */\nexport type GradientShape = \"linear\" | \"radial\" | \"conic\";\n\n/** Geometry knobs for {@link wrapGradient}: `angle` (linear direction / conic `from` rotation), `position`\n * (radial + conic center, e.g. `\"50% 50%\"`, `\"top left\"`), and the radial `shape` / `size`. All optional;\n * the defaults reproduce a centered circle / 90° linear (so omitting them is a no-op). */\nexport interface GradientGeometry {\n angle?: number;\n position?: string;\n radialShape?: \"circle\" | \"ellipse\";\n radialSize?: \"closest-side\" | \"closest-corner\" | \"farthest-side\" | \"farthest-corner\";\n}\n\n/** Wrap a `` in the chosen gradient function (all interpolate `in oklch`). Linear uses the\n * angle as its direction; conic as the `from` rotation about `position`; radial paints a `shape`+`size`\n * centered at `position` (angle unused). */\nexport function wrapGradient(shape: GradientShape, stops: string, geom: GradientGeometry = {}): string {\n const { angle = 90, position = \"50% 50%\", radialShape = \"circle\", radialSize = \"farthest-corner\" } = geom;\n switch (shape) {\n case \"radial\":\n return `radial-gradient(${radialShape} ${radialSize} at ${position} in oklch, ${stops})`;\n case \"conic\":\n return `conic-gradient(from ${angle}deg at ${position} in oklch, ${stops})`;\n default:\n return `linear-gradient(${angle}deg in oklch, ${stops})`;\n }\n}\n\n/**\n * Build a CSS `linear-gradient` from one of the ramps, the seed centered as a slightly wider plateau\n * so the theme color anchors the middle; the rest sit in equal-width bands left → right. `count`\n * steps each side (clamped [3,8]). Interpolated `in oklch` for a perceptual blend.\n * e.g. rampGradient(\"tonal\", { l: 62, c: 0.15, h: 250 }, 5).\n */\n/**\n * THE one axis → colors mapping every backdrop shares (CSS gradient background, canvas background,\n * docs demos), seed-centered with `count` steps per side. Single-sourced so an axis can't mean\n * different math in different engines: `tonal` is the seed's own lightness ramp, gamut-clamped\n * (theme-true — the pure-CSS gradient's original definition), NOT the fixed-chroma tonal scale.\n */\nexport function rampAxisColors(axis: RampGradientAxis, seed: OklchColor, count: number, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n switch (axis) {\n case \"hue\":\n return hueRampColors(seed, count);\n case \"lightness\":\n return lightnessRampColors(seed, count);\n case \"chroma\":\n return chromaRampColors(seed, count, gamut === \"p3\" ? maxP3Chroma(seed.l, seed.h) : maxSrgbChroma(seed.l, seed.h));\n default:\n return lightnessRampColors(seed, count).map((color) => clampToGamut(color, gamut));\n }\n}\n\n/**\n * The lightness range a full-bleed BACKDROP may use while text over it stays readable.\n *\n * The ramps below deliberately cover their whole range — that is what makes them useful as swatches.\n * As a wallpaper it is the opposite of what you want: a lightness ramp runs L 0 → 100, so a single\n * solved foreground is legible at one end of the viewport and invisible at the other. This returns\n * the sub-range on the far side of `fg` where |Lc| stays at or above `targetLc`, measured with the\n * same APCA implementation the rest of the theme uses.\n *\n * Default target is 60 — APCA's non-body level. Body copy sits on veiled glass ABOVE the backdrop and\n * clears 75 comfortably from there; 60 is for the headings that sit directly on it. Asking 75 of the\n * raw backdrop would squeeze light mode into L 87–100 and leave nothing to make a gradient out of.\n *\n * |Lc| is monotonic in backdrop lightness on either side of a foreground, so this binary-searches the\n * boundary rather than sweeping.\n */\nexport function readableLightnessBand(\n fg: OklchColor | string,\n seed: OklchColor,\n targetLc = 60,\n headroom = 7,\n): {\n lMin: number;\n lMax: number;\n} {\n const text = toColor(fg);\n const passes = (l: number) =>\n Math.abs(\n apcaContrast(text, {\n ...seed,\n l,\n }),\n ) >= targetLc;\n /* A dark foreground pushes the backdrop light, and vice versa. */\n const backdropGoesLight = text.l < 50;\n let lo = backdropGoesLight ? 0 : 100;\n let hi = backdropGoesLight ? 100 : 0;\n if (!passes(hi)) {\n /* Nothing on this side clears the target (a mid-lightness foreground). Give back the whole\n range rather than an empty one — banding is a readability aid, not a hard gate. */\n return {\n lMin: 0,\n lMax: 100,\n };\n }\n for (let i = 0; i < 12; i++) {\n const mid = (lo + hi) / 2;\n if (passes(mid)) hi = mid;\n else lo = mid;\n }\n /* Stop `headroom` short of the absolute extreme. That end is the HIGH-contrast one, so backing off\n it costs no readability — but L 0 and L 100 are the two lightnesses that hold no chroma at all, so\n running to them washes the tint out of one edge of the wallpaper. Never let it cross the boundary. */\n const edge = Math.ceil(hi);\n return backdropGoesLight\n ? {\n lMin: edge,\n lMax: Math.max(edge + 1, 100 - headroom),\n }\n : {\n lMin: Math.min(Math.floor(hi) - 1, headroom),\n lMax: Math.floor(hi),\n };\n}\n\n/** Remap a ramp's lightnesses into `band`, preserving the ramp's shape. A ramp whose lightness is\n * constant (the hue and chroma axes) is clamped into the band instead of stretched across it.\n * Exported so the canvas backdrop bands the same ramps the CSS gradient does. */\nexport function fitToBand(\n colors: OklchColor[],\n band: {\n lMin: number;\n lMax: number;\n },\n): OklchColor[] {\n const ls = colors.map((c) => c.l);\n const lo = Math.min(...ls);\n const hi = Math.max(...ls);\n const span = hi - lo;\n if (span < 0.001) {\n const l = Math.min(band.lMax, Math.max(band.lMin, lo));\n return colors.map((c) => ({\n ...c,\n l,\n }));\n }\n return colors.map((c) => ({\n ...c,\n l: band.lMin + ((c.l - lo) / span) * (band.lMax - band.lMin),\n }));\n}\n\n/* Frescoes are authored as fixed multi-hue stops at one lightness, so they skip the ramp entirely —\n which also means they skip its banding and its conic loop-close. Apply both here. */\nexport function bandedFrescoStops(\n colors: string[],\n band:\n | {\n lMin: number;\n lMax: number;\n }\n | undefined,\n shape: GradientShape,\n): string {\n let out = colors;\n if (band) {\n out = out.map((css) => {\n const parsed = parseOklch(css);\n if (!parsed) return css;\n return formatOklch({\n ...parsed,\n l: Math.min(band.lMax, Math.max(band.lMin, parsed.l)),\n });\n });\n }\n if (shape === \"conic\" && out.length > 1) {\n const loop = [\n ...out,\n out[0],\n ];\n return loop.map((css, i) => `${css} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`).join(\", \");\n }\n return out.join(\", \");\n}\n\nexport function rampGradient(\n axis: RampGradientAxis,\n seed: OklchColor,\n count: number,\n options: {\n gamut?: \"srgb\" | \"p3\";\n shape?: GradientShape;\n /** Constrain the ramp's lightness to a readable range — see {@link readableLightnessBand}. */\n band?: {\n lMin: number;\n lMax: number;\n };\n } & GradientGeometry = {},\n): string {\n const { gamut = \"srgb\", shape = \"linear\", band, angle, position, radialShape, radialSize } = options;\n const ramped = rampAxisColors(axis, seed, count, gamut);\n const colors = band ? fitToBand(ramped, band) : ramped;\n\n /* A conic gradient wraps: whatever sits at 360° butts straight into 0°. An open ramp therefore\n meets its own opposite end at the twelve-o'clock line and draws a hard seam right through the\n wallpaper — at full range that is white against black. Close the loop instead, and drop the\n center plateau, which reads as a lopsided wedge once the ramp is bent into a circle.\n hue — already cyclic, so repeating the first color at 360° spaces evenly and joins invisibly.\n other — mirror out and back, giving a symmetric sweep that also ends where it started. */\n if (shape === \"conic\") {\n const loop =\n axis === \"hue\"\n ? [\n ...colors,\n colors[0],\n ]\n : [\n ...colors,\n ...colors.slice(0, -1).reverse(),\n ];\n const stops = loop.map((color, i) => `${formatOklch(color)} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`);\n return wrapGradient(shape, stops.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n }\n\n const mid = Math.floor(colors.length / 2);\n const plateau = 7; // half-width (%) of the centered theme-color band\n const leftEnd = 50 - plateau;\n const rightStart = 50 + plateau;\n const parts = colors.map((color, i) => {\n const css = formatOklch(color);\n if (i === mid) return `${css} ${leftEnd}%, ${css} ${rightStart}%`;\n const pos = i < mid ? (i / mid) * leftEnd : rightStart + ((i - mid) / mid) * (100 - rightStart);\n return `${css} ${pos.toFixed(1)}%`;\n });\n return wrapGradient(shape, parts.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n}\n\n// ── APCA contrast (APCA-W3 / ARC) ─────────────────────────────────────────────\n// Inlined port of the APCA-W3 reference (© Andrew Somers / Myndex Research, licensed to the W3/AGWG).\n// The APCA constants below are the LOCKED beta set (0.1.x) and are used UNMODIFIED. APCA/ARC measures\n// readability contrast — it is NOT a means to claim WCAG 2 conformance. Verified to match apca-w3's\n// APCAcontrast() — see scripts/apca-oracle.mjs. Lc is signed: positive = dark text on a light\n// background, negative = light text on a dark one; |Lc| is the perceptual level (~45 large/UI, ~60\n// other content, ~75 body, ~90 fine). Polarity-aware — which WCAG-2's ratio is not — so it fits dark\n// mode + tinted glass far better.\n\n/** OKLCH (l 0–100) → gamma-encoded sRGB channels in [0, 1] (out-of-gamut values are clipped). */\nexport function oklchToSrgb(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n const [lr, lg, lb] = oklchToLinearSrgb(l, c, h);\n const encode = (v: number) => {\n const x = Math.max(0, Math.min(1, v));\n return x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055;\n };\n return [\n encode(lr),\n encode(lg),\n encode(lb),\n ];\n}\n\n/** Gamma-encoded sRGB channels in [0, 1] → OKLCH (l 0–100). Exact inverse of {@link oklchToSrgb} for\n * in-gamut input, so a composite→decompose round trip is lossless. */\nexport function srgbToOklch([r, g, b]: [\n number,\n number,\n number,\n]): OklchColor {\n const decode = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const [lr, lg, lb] = [\n decode(r),\n decode(g),\n decode(b),\n ];\n const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);\n const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);\n const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);\n const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;\n const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;\n const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;\n return {\n l: L * 100,\n c: Math.hypot(a, bb),\n h: wrapHue((Math.atan2(bb, a) * 180) / Math.PI),\n };\n}\n\n/**\n * Composite a stack of translucent OKLCH layers THE WAY THE BROWSER DOES — CSS source-over on\n * gamma-encoded sRGB channels — and report the result in OKLCH.\n *\n * The obvious shortcut is to lerp the OKLCH coordinates instead (`l1·(1−a) + l2·a`, same for chroma).\n * That is a different operation and it is measurably wrong: OKLCH lightness is a cube-root-ish\n * perceptual scale, sRGB is a ~2.2 gamma, and chroma is a POLAR coordinate, so lerping it mixes two\n * hues along a straight line through the chroma plane rather than around it. Measured against the\n * shipped presets, the shortcut overstated light-mode body contrast by up to 2.8 Lc on the veiled\n * surface — enough to hide four presets sitting under the ARC body floor — while on the crystal\n * surface the error CHANGES SIGN by preset (+0.95 carnelian, −2.66 aventurine). That sign flip is why\n * this is a compositing fix and not a fudge factor: no single correction term can be both.\n *\n * `layers` paint bottom-first; `base` is fully opaque. Each layer's alpha is its own (a layer at\n * alpha 1 simply replaces what is under it). Hue is carried through from the top-most layer that\n * actually contributed chroma, so a neutral stack keeps its nominal hue instead of collapsing to 0.\n */\nexport function compositeSurface(\n base: OklchColor,\n layers: Array<\n OklchColor & {\n a: number;\n }\n >,\n): OklchColor {\n let px = oklchToSrgb(base.l, base.c, base.h);\n let hue = base.h;\n for (const layer of layers) {\n const a = Math.min(Math.max(layer.a, 0), 1);\n if (a <= 0) continue;\n const src = oklchToSrgb(layer.l, layer.c, layer.h);\n px = [\n src[0] * a + px[0] * (1 - a),\n src[1] * a + px[1] * (1 - a),\n src[2] * a + px[2] * (1 - a),\n ];\n if (layer.c > 0) hue = layer.h;\n }\n const out = srgbToOklch(px);\n // Below this the hue angle is numerical noise off a near-neutral pixel — keep the stack's nominal\n // hue so callers that tint from the surface (readableForeground, the ramp base) stay on the theme.\n return out.c < 1e-4\n ? {\n ...out,\n h: hue,\n }\n : out;\n}\n\n/** APCA screen luminance (Ys) from gamma-encoded sRGB. */\nfunction apcaLuminance([r, g, b]: [\n number,\n number,\n number,\n]): number {\n return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;\n}\n\n/**\n * APCA lightness contrast (Lc) between a text color and a background color. Signed: positive = dark\n * text on a light bg, negative = light text on a dark bg; use |Lc| for the level (~60 headings,\n * ~75 body text). A dependency-free port of APCA-W3 0.1.9.\n */\nexport function apcaContrast(text: OklchColor | string, bg: OklchColor | string): number {\n const t = toColor(text);\n const b = toColor(bg);\n let txtY = apcaLuminance(oklchToSrgb(t.l, t.c, t.h));\n let bgY = apcaLuminance(oklchToSrgb(b.l, b.c, b.h));\n const blkThrs = 0.022;\n // biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA blkClmp tuning constant, not √2\n const blkClmp = 1.414;\n txtY = txtY > blkThrs ? txtY : txtY + (blkThrs - txtY) ** blkClmp;\n bgY = bgY > blkThrs ? bgY : bgY + (blkThrs - bgY) ** blkClmp;\n if (Math.abs(bgY - txtY) < 0.0005) return 0;\n let lc: number;\n if (bgY > txtY) {\n const sapc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;\n lc = sapc < 0.1 ? 0 : sapc - 0.027;\n } else {\n const sapc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;\n lc = sapc > -0.1 ? 0 : sapc + 0.027;\n }\n return lc * 100;\n}\n\nconst FG_LIGHT: OklchColor = {\n l: 100,\n c: 0,\n h: 0,\n};\nconst FG_DARK: OklchColor = {\n l: 15,\n c: 0,\n h: 0,\n};\n\n/**\n * Pick whichever foreground has the higher APCA contrast on `bg` — by default near-white vs the\n * near-black `--foreground`. The light, perceptual way to choose a readable text/icon color: cheap\n * enough to run on a theme/tint change (microseconds), no per-frame work, no canvas readback.\n */\nexport function pickForeground(bg: OklchColor | string, light: OklchColor = FG_LIGHT, dark: OklchColor = FG_DARK): OklchColor {\n return Math.abs(apcaContrast(light, bg)) >= Math.abs(apcaContrast(dark, bg)) ? light : dark;\n}\n\n/**\n * From `ramp`, prefer colors whose |APCA Lc| on `surface` falls within [floor, ceiling] and sit closest\n * to `target` — so the floor is honored as a MINIMUM (never undershot) and the ceiling caps the spike.\n * Draws a readable foreground from a real palette (a tonal / lightness ramp) instead of a neutral gray,\n * so text keeps the theme's color while hitting its band. If nothing lands in band (ramp too coarse or\n * the surface can't support it), returns the color nearest the band edge.\n */\nexport function pickInBand(\n ramp: OklchColor[],\n surface: OklchColor | string,\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n): OklchColor {\n const scored = ramp.map((c) => ({\n c,\n lc: Math.abs(apcaContrast(c, surface)),\n }));\n const inBand = scored.filter((s) => s.lc >= band.floor && s.lc <= band.ceiling);\n const pool = inBand.length ? inBand : scored;\n const err = (lc: number) => (inBand.length ? Math.abs(lc - band.target) : Math.min(Math.abs(lc - band.floor), Math.abs(lc - band.ceiling)));\n return pool.reduce((best, s) => (err(s.lc) < err(best.lc) ? s : best), pool[0]).c;\n}\n\n/**\n * The effective glass surface color for the active theme + tint — the theme's light/dark floor\n * blended with the tint wash, mirroring the glass-* utilities (the wash sits at a FIXED lightness,\n * 72 light / 58 dark; only hue, chroma and alpha vary). Pair with pickForeground to choose readable\n * text on a tinted glass surface without reading pixels back.\n */\nexport function glassSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const washL = dark ? 58 : 72;\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: washL,\n c: tint.c * 2.5,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/**\n * The effective glass-SOLID surface color — the legible floor body text actually sits on (never sheer\n * glass). The solid floor (neutral `--glass-solid-l`, 99 light / 18 dark) is composited over the base\n * at `solidA` (the `--glass-solid-a` opacity, ~0.3–0.75), then the tint wash on top. Because that floor\n * is a KNOWN surface, banding text against this — rather than the sheer estimate — gives a real Lc.\n */\nexport function glassSolidSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n solidA: number,\n // Wash knobs — pass the LIVE --glass-wash-l / --glass-wash-c-mult when a theme overrides them\n // (moonstone night: 72 / 2), else the defaults mirror the CSS mode values exactly.\n washL: number = dark ? 58 : 72,\n washCMult = 2.5,\n // The SOLIDIFY floor — `glass` paints --glass-solidify (--glass-opaque-bg at --glass-opacity, default\n // 0.7) as its bottom background-IMAGE layer, i.e. above the background-color modeled by solidA and\n // below the wash. Omitting it models a surface far sheerer than what ships: on most themes the opaque\n // floor sits the same side of mid-grey as the page, so the error is a few Lc, but where the two\n // OPPOSE — moonstone night pairs a cream L84.9 floor with an L20 page — the model lands 27 L on the\n // wrong side and picks text of the wrong polarity entirely. Pass { l, c, a } to include it.\n solidify?: {\n l: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const solidL = dark ? 18 : 99;\n // The real paint order, composited in the real space — see compositeSurface for why lerping the\n // OKLCH coordinates instead is not the same operation.\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: solidL,\n c: 0,\n h: tint.h,\n a: solidA,\n },\n ...(solidify\n ? [\n {\n l: solidify.l,\n c: solidify.c,\n h: tint.h,\n a: solidify.a,\n },\n ]\n : []),\n {\n l: washL,\n c: tint.c * washCMult,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/** Options for {@link themeForeground}. */\nexport interface ThemeForegroundOptions {\n /** Which of the ramp generator's axes the text levels follow. */\n palette: \"tonal\" | \"lightness\" | \"hue\" | \"chroma\";\n /** Text level: 0 = the first swatch; each +1 is one step along the ramp. */\n level: number;\n /** Total steps (the ramp generator's count). */\n count: number;\n /** The chosen color the ramp is built from (the ramp generator's base). */\n base: {\n l: number;\n c: number;\n h: number;\n };\n /** Dark theme? Picks which lightness extreme the tonal/lightness ramps start from. */\n dark: boolean;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * Walk one of the ramp generator's ramps — built from the chosen `base` color — mapping ramp steps\n * to text levels. The ramp is **base-centered with `count` steps EITHER SIDE** (so it matches the\n * ramp generator's \"steps each side\"): level 0 = the readable extreme, level `count` = the base\n * (center), level `2·count` = the opposite extreme — `2·count + 1` levels total. The readable half\n * (levels 0..count) is unchanged, so the live --foreground / --muted-foreground cascade (which only\n * reads that half) is unaffected. `palette` picks the axis:\n * - tonal / lightness: level 0 is the readable lightness extreme (white in dark mode, black in\n * light), easing to the base's lightness at `count`, then on to the opposite extreme;\n * - hue: constant L + C — level 0 is base + count steps, ramping through the base and on the\n * other way (e.g. base `oklch(60% 0.15 255)`, count 8 → level 0 `oklch(60% 0.15 64.4)`);\n * - chroma: constant L + hue, chroma 0 → base → the gamut-displayable max.\n * Gamut-clamped.\n */\nexport function themeForeground(options: ThemeForegroundOptions): OklchColor {\n const { palette, level, count, base, dark, gamut = \"srgb\" } = options;\n // Piecewise around the centered base: readable extreme (level 0) → base (level count) → other.\n const span = (readable: number, mid: number, other: number) => {\n if (count <= 0) return mid;\n if (level <= count) return readable + (mid - readable) * (level / count);\n return mid + (other - mid) * Math.min(1, (level - count) / count);\n };\n const readableL = dark ? 100 : 0;\n const otherL = dark ? 0 : 100;\n const chromaCap = gamut === \"p3\" ? maxP3Chroma(base.l, base.h) : maxSrgbChroma(base.l, base.h);\n let color: OklchColor;\n switch (palette) {\n case \"hue\":\n color = {\n l: base.l,\n c: base.c,\n h: wrapHue(base.h + (count - level) * (360 / (2 * count + 1))),\n };\n break;\n case \"chroma\":\n color = {\n l: base.l,\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n break;\n case \"lightness\":\n color = {\n l: span(readableL, base.l, otherL),\n c: base.c,\n h: base.h,\n };\n break;\n default:\n color = {\n l: span(readableL, base.l, otherL),\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n }\n return clampToGamut(color, gamut);\n}\n\n// ── Readable foreground (soft contrast / ARC Bronze Simple Mode) ──────────────\n// pickForeground() maximizes contrast — it slams to the lightness extreme (pure white / near-black),\n// which reads as a glare/heaviness \"spike\". readableForeground() instead AIMS for a target APCA Lc\n// and stops: legible, but soft. The usage presets follow the ARC \"Bronze Simple Mode\" criterion\n// (readtech.org/ARC) — content-text thresholds, no font-lookup table: body Lc 75 (preferred 90),\n// other content Lc 60, large-fluent (>36px) Lc 45 with a MAX of Lc 90 to \"prevent excessive contrast\"\n// (so the ceiling is straight from the spec). floor = legible minimum, target = aim, ceiling = the cap.\n// `ui` / `non-text` / `disabled` are OUR extension — Bronze scopes to content text, excluding spot text.\n\n/** APCA-derived contrast bands per use case. floor = legible minimum, target = aim, ceiling = cap. */\nexport const READABLE_USAGE = {\n /** Fine / thin / small text — needs the most contrast; little soft room. */\n small: {\n floor: 90,\n target: 90,\n ceiling: 100,\n },\n /** Body text (~16px / 400) — ARC Bronze body: min 75, preferred 90. */\n body: {\n floor: 75,\n target: 80,\n ceiling: 90,\n },\n /** Large or fluent body (~18–24px) / semibold. */\n \"body-large\": {\n floor: 60,\n target: 72,\n ceiling: 88,\n },\n /** Headings / other content text — ARC Bronze \"other content\": min 60. */\n heading: {\n floor: 60,\n target: 66,\n ceiling: 82,\n },\n /** Large fluent text >36px (or ≥24px bold) — ARC Bronze: min 45, max 90. */\n large: {\n floor: 45,\n target: 58,\n ceiling: 76,\n },\n /** UI labels, icons, focus rings — beyond ARC Bronze scope (Bronze is content-text only). Target at the\n * APCA \"other content\" level (60); ceiling 70 stays below body's 75 floor, so icons read quieter than text. */\n ui: {\n floor: 45,\n target: 60,\n ceiling: 70,\n },\n /** Non-text: borders, dividers, graphics — beyond ARC Bronze scope. */\n \"non-text\": {\n floor: 30,\n target: 40,\n ceiling: 58,\n },\n /** Disabled / placeholder — beyond ARC Bronze scope (spot text). */\n disabled: {\n floor: 30,\n target: 33,\n ceiling: 46,\n },\n} as const;\n\nexport type ReadableUsage = keyof typeof READABLE_USAGE;\n\n/** Options for {@link readableForeground}. */\nexport interface ReadableForegroundOptions {\n /** Use-case preset that sets the {floor, target, ceiling} band (default \"body\"). */\n usage?: ReadableUsage;\n /** Aim for this |APCA Lc| — overrides the usage preset's target. */\n target?: number;\n /** Never exceed this |Lc| — the anti-spike cap; overrides the usage preset. */\n ceiling?: number;\n /** Legibility minimum; if even the extreme can't reach it you get max contrast (add a scrim). */\n floor?: number;\n /** Hue to keep for tinted-but-readable text. Defaults to the background's hue. */\n hue?: number;\n /** Chroma to keep, gamut-clamped at the chosen lightness. 0 = neutral gray. */\n chroma?: number;\n /** Opt-in chroma floor (capped at `chroma`). When the band pins the pick to the lightness EXTREME\n * (target/floor beyond the surface's reach), the gamut annihilates the requested chroma there and\n * tinted text reads pure black/white; this backs the aim off to the most contrast available at a\n * lightness that still HOLDS this much chroma — never below an ACHIEVABLE band floor. Reachable\n * bands are unaffected. 0 (default) = off. */\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * A foreground that AIMS for a target APCA contrast on `bg` instead of maximizing it — legible\n * without the harsh pure-black / pure-white spike. Picks the lightness direction with headroom and\n * binary-searches the NEAREST lightness whose |Lc| meets the target (capped at the ceiling); keeps an\n * optional hue/chroma for tinted-but-readable text. The soft-contrast companion to {@link pickForeground}.\n * If even the lightness extreme can't reach the floor (a mid-gray background), it returns the maximum\n * contrast available — the signal that the text needs a scrim / solid backing.\n */\nexport function readableForeground(bg: OklchColor | string, opts: ReadableForegroundOptions = {}): OklchColor {\n const band = READABLE_USAGE[opts.usage ?? \"body\"];\n const target = opts.target ?? band.target;\n const ceiling = opts.ceiling ?? band.ceiling;\n const floor = opts.floor ?? band.floor;\n const { chroma = 0, minChroma = 0, gamut = \"srgb\" } = opts;\n\n const b = toColor(bg);\n const h = opts.hue ?? b.h;\n const cAt = (l: number) => Math.min(chroma, gamut === \"p3\" ? maxP3Chroma(l, h) : maxSrgbChroma(l, h));\n const lcAt = (l: number) =>\n Math.abs(\n apcaContrast(\n {\n l,\n c: cAt(l),\n h,\n },\n b,\n ),\n );\n\n const extreme = lcAt(0) >= lcAt(100) ? 0 : 100; // the lightness direction with headroom\n const reach = lcAt(extreme); // the most contrast this background can give\n // Aim for the target, never past the ceiling; if we can't even clear the floor, take the max.\n let want = reach < floor ? reach : Math.min(target, ceiling, reach);\n\n // Chroma preservation (opt-in, minChroma > 0): want === reach means the band sits beyond what this\n // surface can give, so the search below would pin to the lightness EXTREME — where the gamut\n // annihilates the requested chroma and tinted text reads pure black/white (moonstone-dark cream: reach\n // ≈ 68 < body floor 75 → L≈0 → a chosen accent renders BLACK). Trade the few unreachable Lc points\n // for color: aim instead at the most contrast available at a lightness that still HOLDS the kept\n // chroma — never below the band floor when the floor is achievable. In-reach bands never get here.\n const keep = Math.min(minChroma, chroma);\n if (keep > 0 && want >= reach) {\n // Strict in-gamut test at the kept chroma (÷0.97 so the margined cAt still returns ≥ keep). The\n // shared eps-tolerant gamut test reports PHANTOM chroma at L≈0 — cubed near-zero channels sit\n // inside its 1e-4 tolerance (up to ~0.11 at red/pink hues) — which would converge this search\n // right back to the extreme; eps 1e-6 rejects it.\n const holdsKeep = (l: number) => {\n const [r, g, b3] = gamut === \"p3\" ? oklchToLinearP3(l, keep / 0.97, h) : oklchToLinearSrgb(l, keep / 0.97, h);\n const eps = 1e-6;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b3 >= -eps && b3 <= 1 + eps;\n };\n // Max chroma is ~unimodal in lightness and 0 at both extremes: holdsKeep is false at the extreme\n // and (guarded) true at bg.l, so bisect for the holding lightness NEAREST the extreme.\n if (holdsKeep(b.l)) {\n let kLo = extreme;\n let kHi = b.l;\n for (let i = 0; i < 24; i++) {\n const mid = (kLo + kHi) / 2;\n if (holdsKeep(mid)) kHi = mid;\n else kLo = mid;\n }\n want = Math.min(want, Math.max(lcAt(kHi), reach >= floor ? floor : 0));\n }\n }\n\n // |Lc| rises monotonically from bg.l → the extreme, so binary-search the nearest L meeting `want`.\n let lo = b.l;\n let hi = extreme;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (lcAt(mid) < want) lo = mid;\n else hi = mid;\n }\n return clampToGamut(\n {\n l: hi,\n c: cAt(hi),\n h,\n },\n gamut,\n );\n}\n", "type": "registry:lib", "target": "lib/oklch-utils.ts" } From d89547d92c4daa14afb71325fbabaf520f197f9e Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:04:49 -0700 Subject: [PATCH 04/12] Branch is green. Everything passes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``` ✓ preset contrast — 42 preset/mode pairs clear the 75 Lc body floor (tightest destructive light at 76.5) ✓ tint gamut — worst spread 1.88× (max 4.6), worst overage 1.88× (max 4.6) ✓ theme invariants — 121 rules, 17 presets value-synced, theme.json in sync ✓ 155 tests · lint · typecheck · APCA oracle (max |ΔLc| 0.000) · registry smoke (71 items) ``` `--glass-wash-l` only, light mode, five presets: | preset | light wash | dark | |---|---|---| | amethyst | 55 → **57** | pinned 55 | | rose | 64 → **67** | pinned 64 | | tourmaline | 67 → **69.5** | pinned 67 | | goldstone | 63 → **67** | pinned 63 | | destructive | 63 → **68.5** | new `.dark` block, pinned 63 | Your constraints held: **`--glass-tint-a` untouched**, opaque cards untouched, dark mode byte-identical. **Your light-mode-only framing was load-bearing.** It doesn't change the math — every number was already `dark: false` — but it does change the *mechanism*. `presets.css` loads at `globals.css:21`, after `tokens.css` at 16, and at equal specificity source order wins. So a preset's base block already overrode `.dark { --glass-wash-l: 58% }` and applied in **both** modes. Scoping to light required explicitly pinning today's values into the `.dark` blocks (specificity 0,2,0), not just editing the base. **And it killed my recommended plan.** I was about to split `--glass-tint-c` per mode. `applyTint` at `glass-tint-switcher.tsx:285` inlines `--glass-tint-c` onto `` unconditionally, which shadows *both* blocks — the exact trap that file documents for alpha at lines 273–276, where alpha is passed as `null` specifically so the split survives. That fix would have silently done nothing on any page with the switcher mounted. `--glass-wash-l` is never inlined, so it's the one knob that can differ per mode and actually render. Since I couldn't move chroma, the raised washes ask slightly more than the gamut holds and clip per-channel. Measured cost: **≤0.6° hue, ≤5% chroma**, and gamut overage stayed at 1.88× — my changes land near 1.1×, so they didn't become the worst case. The contrast numbers already account for it, since the model clips. `registry/theme/globals.css` is **generated** by `scripts/build-theme.mjs`, not hand-maintained — my first pass hand-edited it, which `check-theme` correctly flagged as stale. Regenerated via `registry:check`; `public/r/theme.json` updated too. Uncommitted: `presets.css`, `registry/theme/globals.css`, `public/r/theme.json`. I see you committed the compositing work as `21ae7bd`. Two open items I'd still flag, both unchanged by this: the **ink-vs-surface hue mismatch** (up to 6° on goldstone/rose), and that none of this delivers tonal ink at L30 — that still needs the light-wash-per-mode character decision we mapped out. --- app/theme/presets.css | 35 +++++++++++++++++++++++++++++++---- public/r/theme.json | 2 +- registry/theme/globals.css | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/app/theme/presets.css b/app/theme/presets.css index 3300b60..272d01c 100644 --- a/app/theme/presets.css +++ b/app/theme/presets.css @@ -21,14 +21,16 @@ --glass-tint-h: 300; --glass-tint-c: 0.101; --glass-tint-a: 0.15; - --glass-wash-l: 55%; + /* LIGHT-mode wash, raised off this hue's chroma peak to buy body-text contrast — see the note above + the .dark block below. Dark mode pins the peak value back. */ + --glass-wash-l: 57%; --glass-opaque-c-max: 0.065; } [data-glass-tint="rose"] { --glass-tint-h: 8; --glass-tint-c: 0.095; --glass-tint-a: 0.17; - --glass-wash-l: 64%; + --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ --glass-opaque-c-max: 0.062; } [data-glass-tint="amber"] { @@ -70,7 +72,7 @@ --glass-tint-h: 342; --glass-tint-c: 0.106; --glass-tint-a: 0.18; - --glass-wash-l: 67%; + --glass-wash-l: 69.5%; /* light-mode wash — see the .dark block note */ --glass-opaque-c-max: 0.079; } [data-glass-tint="lapis"] { @@ -95,7 +97,7 @@ --glass-tint-h: 22; --glass-tint-c: 0.094; --glass-tint-a: 0.17; - --glass-wash-l: 63%; + --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ --glass-opaque-c-max: 0.06; } /* ── Per-mode wash ALPHA ────────────────────────────────────────────────────── @@ -111,11 +113,27 @@ presets lose dark-mode text and lapis/amethyst lose light-mode text. The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the values that render — nothing shadows them. */ +/* ── Per-mode wash LIGHTNESS (five presets) ─────────────────────────────────── + Each wash sits at its hue's sRGB chroma PEAK (see the lapis note). For the blue-purple-red arc that + peak is DARK, which in LIGHT mode pulls the veiled surface down to L84–86 — where body text bottoms + out at 74.6–76.0 Lc, at or under the 75 floor. (It read as passing until the surface model was fixed + to composite in sRGB rather than lerp OKLCH coordinates, which had overstated light contrast by up + to 2.8 Lc.) Raising each LIGHT wash 2–5.5 L lifts the surface enough to clear the floor with margin. + Alpha is deliberately NOT the lever here: it is per-mode already, and lowering it desaturates. + --glass-tint-c is not either — glass-tint-switcher.tsx INLINES it (applyTint), which would shadow any + per-mode split, the same trap documented there for alpha. --glass-wash-l is never inlined, so it is + the one knob that can differ per mode and still render. + Above its peak a wash asks more chroma than the gamut holds and the browser clips per channel; + measured, that costs ≤0.6° of hue and ≤5% chroma here, and check-gamut's overage stays ~1.1× against + a 4.6× limit. DARK mode already clears the floor everywhere, so each block below pins the original + peak value rather than inheriting the light one. */ .dark[data-glass-tint="rose"] { --glass-tint-a: 0.49; + --glass-wash-l: 64%; } .dark[data-glass-tint="goldstone"] { --glass-tint-a: 0.5; + --glass-wash-l: 63%; } .dark[data-glass-tint="carnelian"] { --glass-tint-a: 0.45; @@ -143,9 +161,11 @@ } .dark[data-glass-tint="amethyst"] { --glass-tint-a: 0.54; + --glass-wash-l: 55%; } .dark[data-glass-tint="tourmaline"] { --glass-tint-a: 0.46; + --glass-wash-l: 67%; } [data-glass-tint="moonstone"] { /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent @@ -262,5 +282,12 @@ --glass-tint-h: 25; --glass-tint-c: 0.094; --glass-tint-a: 0.18; + --glass-wash-l: 68.5%; /* light-mode wash — same reasoning as the jewels, see the per-mode note above */ +} +/* The only STATUS preset needing the per-mode wash split: at the shared 63% its light body text reached + 74.6 Lc, the one outright floor miss in the system. Dark clears it, so pin the original here. Both + selectors are carried so an error toast tracks the alert. */ +.dark[data-glass-tint="destructive"], +.dark [data-sonner-toast][data-type="error"] { --glass-wash-l: 63%; } diff --git a/public/r/theme.json b/public/r/theme.json index f88eb9c..837d4d8 100644 --- a/public/r/theme.json +++ b/public/r/theme.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/theme/globals.css", - "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-opaque-bg) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", + "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-opaque-bg) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n /* LIGHT-mode wash, raised off this hue's chroma peak to buy body-text contrast — see the note above\n the .dark block below. Dark mode pins the peak value back. */\n --glass-wash-l: 57%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 69.5%; /* light-mode wash — see the .dark block note */\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n/* ── Per-mode wash LIGHTNESS (five presets) ───────────────────────────────────\n Each wash sits at its hue's sRGB chroma PEAK (see the lapis note). For the blue-purple-red arc that\n peak is DARK, which in LIGHT mode pulls the veiled surface down to L84–86 — where body text bottoms\n out at 74.6–76.0 Lc, at or under the 75 floor. (It read as passing until the surface model was fixed\n to composite in sRGB rather than lerp OKLCH coordinates, which had overstated light contrast by up\n to 2.8 Lc.) Raising each LIGHT wash 2–5.5 L lifts the surface enough to clear the floor with margin.\n Alpha is deliberately NOT the lever here: it is per-mode already, and lowering it desaturates.\n --glass-tint-c is not either — glass-tint-switcher.tsx INLINES it (applyTint), which would shadow any\n per-mode split, the same trap documented there for alpha. --glass-wash-l is never inlined, so it is\n the one knob that can differ per mode and still render.\n Above its peak a wash asks more chroma than the gamut holds and the browser clips per channel;\n measured, that costs ≤0.6° of hue and ≤5% chroma here, and check-gamut's overage stays ~1.1× against\n a 4.6× limit. DARK mode already clears the floor everywhere, so each block below pins the original\n peak value rather than inheriting the light one. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n --glass-wash-l: 64%;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n --glass-wash-l: 63%;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n --glass-wash-l: 55%;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n --glass-wash-l: 67%;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 68.5%; /* light-mode wash — same reasoning as the jewels, see the per-mode note above */\n}\n/* The only STATUS preset needing the per-mode wash split: at the shared 63% its light body text reached\n 74.6 Lc, the one outright floor miss in the system. Dark clears it, so pin the original here. Both\n selectors are carried so an error toast tracks the alert. */\n.dark[data-glass-tint=\"destructive\"],\n.dark [data-sonner-toast][data-type=\"error\"] {\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", "type": "registry:style", "target": "app/globals.css" } diff --git a/registry/theme/globals.css b/registry/theme/globals.css index 479481f..53ac529 100644 --- a/registry/theme/globals.css +++ b/registry/theme/globals.css @@ -1647,14 +1647,16 @@ --glass-tint-h: 300; --glass-tint-c: 0.101; --glass-tint-a: 0.15; - --glass-wash-l: 55%; + /* LIGHT-mode wash, raised off this hue's chroma peak to buy body-text contrast — see the note above + the .dark block below. Dark mode pins the peak value back. */ + --glass-wash-l: 57%; --glass-opaque-c-max: 0.065; } [data-glass-tint="rose"] { --glass-tint-h: 8; --glass-tint-c: 0.095; --glass-tint-a: 0.17; - --glass-wash-l: 64%; + --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ --glass-opaque-c-max: 0.062; } [data-glass-tint="amber"] { @@ -1696,7 +1698,7 @@ --glass-tint-h: 342; --glass-tint-c: 0.106; --glass-tint-a: 0.18; - --glass-wash-l: 67%; + --glass-wash-l: 69.5%; /* light-mode wash — see the .dark block note */ --glass-opaque-c-max: 0.079; } [data-glass-tint="lapis"] { @@ -1721,7 +1723,7 @@ --glass-tint-h: 22; --glass-tint-c: 0.094; --glass-tint-a: 0.17; - --glass-wash-l: 63%; + --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ --glass-opaque-c-max: 0.06; } /* ── Per-mode wash ALPHA ────────────────────────────────────────────────────── @@ -1737,11 +1739,27 @@ presets lose dark-mode text and lapis/amethyst lose light-mode text. The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the values that render — nothing shadows them. */ +/* ── Per-mode wash LIGHTNESS (five presets) ─────────────────────────────────── + Each wash sits at its hue's sRGB chroma PEAK (see the lapis note). For the blue-purple-red arc that + peak is DARK, which in LIGHT mode pulls the veiled surface down to L84–86 — where body text bottoms + out at 74.6–76.0 Lc, at or under the 75 floor. (It read as passing until the surface model was fixed + to composite in sRGB rather than lerp OKLCH coordinates, which had overstated light contrast by up + to 2.8 Lc.) Raising each LIGHT wash 2–5.5 L lifts the surface enough to clear the floor with margin. + Alpha is deliberately NOT the lever here: it is per-mode already, and lowering it desaturates. + --glass-tint-c is not either — glass-tint-switcher.tsx INLINES it (applyTint), which would shadow any + per-mode split, the same trap documented there for alpha. --glass-wash-l is never inlined, so it is + the one knob that can differ per mode and still render. + Above its peak a wash asks more chroma than the gamut holds and the browser clips per channel; + measured, that costs ≤0.6° of hue and ≤5% chroma here, and check-gamut's overage stays ~1.1× against + a 4.6× limit. DARK mode already clears the floor everywhere, so each block below pins the original + peak value rather than inheriting the light one. */ .dark[data-glass-tint="rose"] { --glass-tint-a: 0.49; + --glass-wash-l: 64%; } .dark[data-glass-tint="goldstone"] { --glass-tint-a: 0.5; + --glass-wash-l: 63%; } .dark[data-glass-tint="carnelian"] { --glass-tint-a: 0.45; @@ -1769,9 +1787,11 @@ } .dark[data-glass-tint="amethyst"] { --glass-tint-a: 0.54; + --glass-wash-l: 55%; } .dark[data-glass-tint="tourmaline"] { --glass-tint-a: 0.46; + --glass-wash-l: 67%; } [data-glass-tint="moonstone"] { /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent @@ -1888,6 +1908,13 @@ --glass-tint-h: 25; --glass-tint-c: 0.094; --glass-tint-a: 0.18; + --glass-wash-l: 68.5%; /* light-mode wash — same reasoning as the jewels, see the per-mode note above */ +} +/* The only STATUS preset needing the per-mode wash split: at the shared 63% its light body text reached + 74.6 Lc, the one outright floor miss in the system. Dark clears it, so pin the original here. Both + selectors are carried so an error toast tracks the alert. */ +.dark[data-glass-tint="destructive"], +.dark [data-sonner-toast][data-type="error"] { --glass-wash-l: 63%; } /* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its From fc4e652f2e59886c22c9dbe60ec762565de7892e Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:10:39 -0700 Subject: [PATCH 05/12] Fixed and verified. The preset work landed in `d89547d`; this is a separate change on top. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--glass-tint-h` describes the **wash**. What a reader sees is that wash composited over the near-neutral solid/solidify floor — and that mix doesn't travel a radial path through the hue plane, because the floor has almost no hue to interpolate toward. So the surface lands a few degrees off the declared angle, while the ink was being generated at the raw token. Text sat at a hue its own surface never occupied. `components/auto-foreground.tsx` — the foreground ramp is now seeded from the **composited surface hue**, and each adaptive tier (`readableForeground`) solves at *its own* surface's hue rather than `tintH`. Icons follow the same rule when `iconHue` is null; an explicitly pinned hue or harmonic still wins, since those are deliberate choices. Measured on the shipped light presets: | preset | tint-h → surface h | before | after | |---|---|---|---| | rose | 8 → 2.5 | `#30010f` | `#300113` | | goldstone | 22 → 17.1 | `#310105` | `#310108` | | lapis | 268 → 271.7 | `#05014f` | `#0a014d` | | peridot | 128 → 126.6 | `#2d4303` | `#2f4203` | Contrast is untouched — worst ΔLc is **+0.08**. This is a correctness fix, not a visual change. New `tests/auto-foreground-hue-match.test.tsx`. I checked it actually catches the bug rather than just passing: reverting to `tintH` fails **6 of its assertions**, with the ink sitting at exactly `8.00 / 22.00 / 75.00 / 145.00` while surfaces were at `3.30 / 18.50 / 73.93 / 149.13`. It includes a control worth calling out: **opaque must stay at `tint-h`**. That surface is solid-painted with nothing composited over it, so it genuinely has no drift — the assertion guards against a future change blanket-rotating every tier instead of tracking each surface. ``` ✓ 163 tests (15 files, was 155/14) · lint · typecheck ✓ preset contrast — tightest destructive light at 76.5 ✓ tint gamut · theme invariants (121 rules) · APCA oracle 0.000 · registry smoke (71 items) ``` Uncommitted: `components/auto-foreground.tsx`, the new test, and `public/r/auto-foreground.json` (regenerated). One caveat on scope: the residual spread *between* materials is now ≤5.5° (opaque at the tint hue, the three sheer surfaces a few degrees off). At the ink's chroma (~0.08) that's well under a JND, so I treated matching each ink to its own surface as more correct than forcing all four to agree — but if you'd rather every material's text share one angle, that's a different call and easy to switch. The remaining open item is unchanged: tonal ink at L30 still needs the light-wash-per-mode character decision. --- components/auto-foreground.tsx | 56 +++++---- public/r/auto-foreground.json | 2 +- tests/auto-foreground-hue-match.test.tsx | 142 +++++++++++++++++++++++ 3 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 tests/auto-foreground-hue-match.test.tsx diff --git a/components/auto-foreground.tsx b/components/auto-foreground.tsx index 3e029a2..8d52a76 100644 --- a/components/auto-foreground.tsx +++ b/components/auto-foreground.tsx @@ -316,13 +316,37 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH); // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h. const fgHarmonyH = huelessAccent ? accentH : harmonyH; + /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the + ramp, because the ramp's hue comes from it — see below. */ + const normalSurface = glassSolidSurface( + dark, + { + h: tintH, + c: tintC, + a: tintA, + }, + solidA, + washL, + washCMult, + solidifyFloor, + ); + /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares; + what a reader sees is the wash composited over the solid/solidify floor, and that mix does not + travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward + and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone + 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the + surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid + painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly + that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well + under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */ + const surfaceH = huelessAccent ? accentH : normalSurface.h; // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue + // vividnesses the whole ramp so every text tier tints toward the chosen accent. const base = { l: rl ?? storedRamp.l, c: huelessAccent ? accentC || cfgC : palette === "hue" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0, - h: huelessAccent ? accentH : tintH, + h: surfaceH, }; // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. @@ -418,7 +442,9 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor ceiling: band.ceiling, // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent. - hue: huelessAccent ? accentH : tintH, + // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each + // adaptive material composites a different stack, so each gets ink at its own angle. + hue: huelessAccent ? accentH : surface.h, chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0, // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve @@ -466,7 +492,10 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue). - const iconH = typeof iconHue === "string" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === "number" ? iconHue : tintH; + // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon + // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate + // choice and overrides that. + const iconH = typeof iconHue === "string" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === "number" ? iconHue : surface.h; root.style.setProperty( `--foreground-ui${suffix}`, formatOklch( @@ -480,24 +509,9 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor }; // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the - // show-through margin lifting each band target as the floor gets sheerer. - applyTiers( - glassSolidSurface( - dark, - { - h: tintH, - c: num("--glass-tint-c", 0), - a: tintA, - }, - solidA, - washL, - washCMult, - solidifyFloor, - ), - "", - false, - normalLcBoost, - ); + // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp + // (see normalSurface) because the ramp's hue is read off it. + applyTiers(normalSurface, "", false, normalLcBoost); // Opaque cards paint the solid --glass-opaque-bg floor — band a second set against it. This IS the // solidify floor, undiluted: an opaque card is the one surface with nothing sheer above it, so it is // `solidifyFloor` at full strength. Reuse it rather than re-deriving, which is how this drifted — it diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json index 88d79a7..89bf99e 100644 --- a/public/r/auto-foreground.json +++ b/public/r/auto-foreground.json @@ -11,7 +11,7 @@ "files": [ { "path": "components/auto-foreground.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const solidifyFloor = {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: huelessAccent ? accentH : tintH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: \"\" for the\n // normal glass-SOLID surface (page + translucent/solid cards), and \"-opaque\" for the solid\n // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars\n // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while\n // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own.\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n hue: huelessAccent ? accentH : tintH,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE[usage]),\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : tintH;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE.ui),\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer.\n applyTiers(\n glassSolidSurface(\n dark,\n {\n h: tintH,\n c: num(\"--glass-tint-c\", 0),\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n ),\n \"\",\n false,\n normalLcBoost,\n );\n // Opaque cards paint the solid --glass-opaque-bg floor — band a second set against it. This IS the\n // solidify floor, undiluted: an opaque card is the one surface with nothing sheer above it, so it is\n // `solidifyFloor` at full strength. Reuse it rather than re-deriving, which is how this drifted — it\n // carried its own `* 0.9` (a multiplier matching neither mode: tokens.css ships 0.85 light / 1.05\n // dark), its own stale lightness, and no chroma cap at all, so the opaque tier banded against a\n // surface the CSS never paints. One derivation, one place to keep in step with tokens.css.\n // `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n // readable half and can't.\n applyTiers(\n {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const solidifyFloor = {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: \"\" for the\n // normal glass-SOLID surface (page + translucent/solid cards), and \"-opaque\" for the solid\n // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars\n // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while\n // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own.\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE[usage]),\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE.ui),\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n // Opaque cards paint the solid --glass-opaque-bg floor — band a second set against it. This IS the\n // solidify floor, undiluted: an opaque card is the one surface with nothing sheer above it, so it is\n // `solidifyFloor` at full strength. Reuse it rather than re-deriving, which is how this drifted — it\n // carried its own `* 0.9` (a multiplier matching neither mode: tokens.css ships 0.85 light / 1.05\n // dark), its own stale lightness, and no chroma cap at all, so the opaque tier banded against a\n // surface the CSS never paints. One derivation, one place to keep in step with tokens.css.\n // `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n // readable half and can't.\n applyTiers(\n {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", "type": "registry:component", "target": "components/auto-foreground.tsx" } diff --git a/tests/auto-foreground-hue-match.test.tsx b/tests/auto-foreground-hue-match.test.tsx new file mode 100644 index 0000000..13cdfa0 --- /dev/null +++ b/tests/auto-foreground-hue-match.test.tsx @@ -0,0 +1,142 @@ +/** + * Text sits at the hue of the SURFACE it is painted on, not at the hue the tint token declares. + * + * `--glass-tint-h` describes the WASH. What a reader actually sees is that wash composited over the + * solid/solidify floor, and that mix does not travel a radial path through the hue plane: the floor is + * near-neutral, so it has almost no hue to interpolate toward, and the composite lands a few degrees off + * the declared angle. Measured on the shipped light presets: rose 8 → 2.5, goldstone 22 → 17.1, + * lapis 268 → 271.7. Seeding the foreground ramp from `--glass-tint-h` therefore painted text at an angle + * the surface underneath it never occupies. + * + * The gap is small in absolute terms, which is exactly why it needs pinning rather than eyeballing — it + * is invisible in a screenshot and silent in every other suite, but it is the difference between "tonal + * text, same hue as its surface" and "text near the surface's hue". + * + * OPAQUE is the control. It is a solid painted colour with nothing composited over it, so its hue IS + * `--glass-tint-h`; if a change ever blanket-rotated every tier, this is the assertion that would catch it. + */ +import { render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AutoForeground } from "@/components/auto-foreground"; +import { glassSolidSurface, type OklchColor, parseOklch } from "@/lib/oklch-utils"; + +const root = () => document.documentElement; +const read = (name: string) => root().style.getPropertyValue(name).trim(); +const fg = (name: string): OklchColor => { + const c = parseOklch(read(name)); + if (!c) throw new Error(`${name} is not oklch: ${read(name) || "(empty)"}`); + return c; +}; + +/** Signed shortest angular distance a → b, in degrees. */ +const hueGap = (a: number, b: number) => ((((a - b) % 360) + 540) % 360) - 180; + +const TINT_C = 0.09; +const TINT_A = 0.15; + +/** The veiled surface the component models, mirroring its jsdom fallbacks exactly. */ +const normalSurface = (h: number) => + glassSolidSurface( + false, + { + h, + c: TINT_C, + a: TINT_A, + }, + 0.65, + 72, + 2.5, + { + l: 88, + c: Math.min(TINT_C * 0.85, 0.055), + a: 0.7, + }, + ); + +async function mountTinted(hue: number) { + root().style.setProperty("--glass-tint-h", String(hue)); + root().style.setProperty("--glass-tint-c", String(TINT_C)); + root().style.setProperty("--glass-tint-a", String(TINT_A)); + render(); + await waitFor(() => expect(read("--foreground")).not.toBe("")); +} + +beforeEach(() => { + localStorage.clear(); + root().className = ""; + root().removeAttribute("style"); +}); + +afterEach(() => { + localStorage.clear(); + root().className = ""; + root().removeAttribute("style"); +}); + +describe("AutoForeground: ink follows its surface's hue", () => { + it.each([ + [ + "rose-ish", + 8, + ], + [ + "goldstone-ish", + 22, + ], + [ + "warm", + 75, + ], + [ + "green", + 145, + ], + [ + "blue", + 268, + ], + ])("puts --foreground at the composited surface hue on a %s tint", async (_label, hue) => { + await mountTinted(hue); + + const surface = normalSurface(hue); + const ink = fg("--foreground"); + + expect(Math.abs(hueGap(ink.h, surface.h)), `ink h${ink.h.toFixed(2)} vs surface h${surface.h.toFixed(2)}`).toBeLessThan(1); + }); + + it("does NOT simply reuse --glass-tint-h", async () => { + /* The assertion above passes trivially if the surface happens to sit at the tint hue, so prove the + two are actually distinguishable on a hue where the composite drifts most (h8 → ~3.3, 4.7° away). + Without this, seeding the ramp from tintH again would go unnoticed. */ + const hue = 8; + await mountTinted(hue); + + const surface = normalSurface(hue); + expect(Math.abs(hueGap(surface.h, hue)), "surface must drift off the tint hue for this to test anything").toBeGreaterThan(2); + expect(Math.abs(hueGap(fg("--foreground").h, hue))).toBeGreaterThan(2); + }); + + it("keeps the OPAQUE tier at the tint hue, since nothing composites over it", async () => { + /* --glass-opaque-bg is painted solid: no wash above it, so no drift. This is the control that a fix + for the sheer surfaces did not blanket-rotate every tier. */ + const hue = 8; + await mountTinted(hue); + + expect(Math.abs(hueGap(fg("--foreground-opaque").h, hue))).toBeLessThan(1); + }); + + it("matches ink to surface on the crystal and chakra stacks too", async () => { + /* Both composite their own layer stacks, so each lands at its own angle — and each one's text should + follow ITS surface, not the page's. They differ from the normal surface by well under a JND, so the + assertion is that they track the sheer drift rather than sitting back at the tint hue. */ + const hue = 8; + await mountTinted(hue); + + for (const tier of [ + "--foreground-crystal", + "--foreground-chakra", + ]) { + expect(Math.abs(hueGap(fg(tier).h, hue)), `${tier} sat back at the tint hue`).toBeGreaterThan(1); + } + }); +}); From aa00138522dd6bbc1a281c4e2210deb06a6a4c8d Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:24:19 -0700 Subject: [PATCH 06/12] updated registry --- app/theme/engine.css | 8 ++ app/theme/presets.css | 35 +-------- app/theme/tokens.css | 28 +++++++ app/theme/utilities.css | 4 +- components/auto-foreground.tsx | 31 +++++--- public/r/auto-foreground.json | 2 +- public/r/theme.json | 2 +- registry/theme/globals.css | 75 +++++++++++-------- scripts/check-contrast.mjs | 37 ++++++++- tests/auto-foreground-hue-match.test.tsx | 6 +- tests/auto-foreground-surface-parity.test.tsx | 5 +- tests/auto-foreground-tonal-ramp.test.tsx | 14 +++- 12 files changed, 159 insertions(+), 88 deletions(-) diff --git a/app/theme/engine.css b/app/theme/engine.css index 826b973..b2c14c5 100644 --- a/app/theme/engine.css +++ b/app/theme/engine.css @@ -192,6 +192,14 @@ --glass-opaque-bg: oklch( calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h) ); + /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer + material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque + CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has + the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */ + --glass-solidify-bg: oklch( + calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%) + min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h) + ); /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03); diff --git a/app/theme/presets.css b/app/theme/presets.css index 272d01c..3300b60 100644 --- a/app/theme/presets.css +++ b/app/theme/presets.css @@ -21,16 +21,14 @@ --glass-tint-h: 300; --glass-tint-c: 0.101; --glass-tint-a: 0.15; - /* LIGHT-mode wash, raised off this hue's chroma peak to buy body-text contrast — see the note above - the .dark block below. Dark mode pins the peak value back. */ - --glass-wash-l: 57%; + --glass-wash-l: 55%; --glass-opaque-c-max: 0.065; } [data-glass-tint="rose"] { --glass-tint-h: 8; --glass-tint-c: 0.095; --glass-tint-a: 0.17; - --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ + --glass-wash-l: 64%; --glass-opaque-c-max: 0.062; } [data-glass-tint="amber"] { @@ -72,7 +70,7 @@ --glass-tint-h: 342; --glass-tint-c: 0.106; --glass-tint-a: 0.18; - --glass-wash-l: 69.5%; /* light-mode wash — see the .dark block note */ + --glass-wash-l: 67%; --glass-opaque-c-max: 0.079; } [data-glass-tint="lapis"] { @@ -97,7 +95,7 @@ --glass-tint-h: 22; --glass-tint-c: 0.094; --glass-tint-a: 0.17; - --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ + --glass-wash-l: 63%; --glass-opaque-c-max: 0.06; } /* ── Per-mode wash ALPHA ────────────────────────────────────────────────────── @@ -113,27 +111,11 @@ presets lose dark-mode text and lapis/amethyst lose light-mode text. The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the values that render — nothing shadows them. */ -/* ── Per-mode wash LIGHTNESS (five presets) ─────────────────────────────────── - Each wash sits at its hue's sRGB chroma PEAK (see the lapis note). For the blue-purple-red arc that - peak is DARK, which in LIGHT mode pulls the veiled surface down to L84–86 — where body text bottoms - out at 74.6–76.0 Lc, at or under the 75 floor. (It read as passing until the surface model was fixed - to composite in sRGB rather than lerp OKLCH coordinates, which had overstated light contrast by up - to 2.8 Lc.) Raising each LIGHT wash 2–5.5 L lifts the surface enough to clear the floor with margin. - Alpha is deliberately NOT the lever here: it is per-mode already, and lowering it desaturates. - --glass-tint-c is not either — glass-tint-switcher.tsx INLINES it (applyTint), which would shadow any - per-mode split, the same trap documented there for alpha. --glass-wash-l is never inlined, so it is - the one knob that can differ per mode and still render. - Above its peak a wash asks more chroma than the gamut holds and the browser clips per channel; - measured, that costs ≤0.6° of hue and ≤5% chroma here, and check-gamut's overage stays ~1.1× against - a 4.6× limit. DARK mode already clears the floor everywhere, so each block below pins the original - peak value rather than inheriting the light one. */ .dark[data-glass-tint="rose"] { --glass-tint-a: 0.49; - --glass-wash-l: 64%; } .dark[data-glass-tint="goldstone"] { --glass-tint-a: 0.5; - --glass-wash-l: 63%; } .dark[data-glass-tint="carnelian"] { --glass-tint-a: 0.45; @@ -161,11 +143,9 @@ } .dark[data-glass-tint="amethyst"] { --glass-tint-a: 0.54; - --glass-wash-l: 55%; } .dark[data-glass-tint="tourmaline"] { --glass-tint-a: 0.46; - --glass-wash-l: 67%; } [data-glass-tint="moonstone"] { /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent @@ -282,12 +262,5 @@ --glass-tint-h: 25; --glass-tint-c: 0.094; --glass-tint-a: 0.18; - --glass-wash-l: 68.5%; /* light-mode wash — same reasoning as the jewels, see the per-mode note above */ -} -/* The only STATUS preset needing the per-mode wash split: at the shared 63% its light body text reached - 74.6 Lc, the one outright floor miss in the system. Dark clears it, so pin the original here. Both - selectors are carried so an error toast tracks the alert. */ -.dark[data-glass-tint="destructive"], -.dark [data-sonner-toast][data-type="error"] { --glass-wash-l: 63%; } diff --git a/app/theme/tokens.css b/app/theme/tokens.css index 7a30f33..ffe48c0 100644 --- a/app/theme/tokens.css +++ b/app/theme/tokens.css @@ -201,6 +201,28 @@ deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope against its real ceiling rather than trusting this number. */ --glass-opaque-c-max: 0.055; + /* ── Solidify floor — the opaque backing UNDER sheer glass ─────────────────── + `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image + layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on. + It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE + CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer + (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the + same surface; they only shared a token. + Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right + lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is + not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes + what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17 + scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with + no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it + pins both back to the opaque floor below and nothing changes there. */ + --glass-solidify-l: 92; + /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather + than 17 new per-preset numbers, derive from the ones each preset already declares: across the + palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real + ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot, + turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their + surface chroma; the dark-peaking arc keeps 80–86%. */ + --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65); /* ── Chakra — the step-cut material (between opaque and crystal) ───────────── Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets @@ -464,6 +486,12 @@ pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */ /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */ --glass-opaque-c-max: 0.12; + /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode + text contrast, and dark already clears the body floor on every preset with margin. Pinning to the + opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only + change: a lighter backing under dark glass would wash the whole mode out. */ + --glass-solidify-l: var(--glass-opaque-l); + --glass-solidify-c-max: var(--glass-opaque-c-max); /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved). The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28 DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to diff --git a/app/theme/utilities.css b/app/theme/utilities.css index f13a6e4..46520b0 100644 --- a/app/theme/utilities.css +++ b/app/theme/utilities.css @@ -43,7 +43,9 @@ bands text against the sheer floor, so a more solid surface than it modelled could have cost contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */ - --glass-solidify-fill: color-mix(in oklch, var(--glass-opaque-bg) calc(var(--glass-opacity, 0.7) * 100%), transparent); + /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now + (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */ + --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent); --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill)); /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it. `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers diff --git a/components/auto-foreground.tsx b/components/auto-foreground.tsx index 8d52a76..c68ebdf 100644 --- a/components/auto-foreground.tsx +++ b/components/auto-foreground.tsx @@ -275,9 +275,15 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor surface more colourful (and so slightly darker) than the one actually painted; scripts/ check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the guard that signs the presets off. Keep the three in step. */ + const opaqueCMax = num("--glass-opaque-c-max", dark ? 0.12 : 0.055); + /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see + tokens.css). It is the one high-weight term in the composite that is not part of a preset's + declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching + a single tint token. Dark pins both back to the opaque floor, so this reads identically there. + Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */ const solidifyFloor = { - l: num("--glass-opaque-l", dark ? 36.4 : 88), - c: Math.min(tintC * num("--glass-opaque-c-scale", dark ? 1.05 : 0.85), num("--glass-opaque-c-max", dark ? 0.12 : 0.055)), + l: num("--glass-solidify-l", dark ? 36.4 : 92), + c: Math.min(tintC * num("--glass-opaque-c-scale", dark ? 1.05 : 0.85), num("--glass-solidify-c-max", dark ? opaqueCMax : opaqueCMax * 0.65)), a: glassOpacity, }; /** The solidify floor as a compositable layer — the layer order `glass` paints. */ @@ -512,18 +518,19 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp // (see normalSurface) because the ramp's hue is read off it. applyTiers(normalSurface, "", false, normalLcBoost); - // Opaque cards paint the solid --glass-opaque-bg floor — band a second set against it. This IS the - // solidify floor, undiluted: an opaque card is the one surface with nothing sheer above it, so it is - // `solidifyFloor` at full strength. Reuse it rather than re-deriving, which is how this drifted — it - // carried its own `* 0.9` (a multiplier matching neither mode: tokens.css ships 0.85 light / 1.05 - // dark), its own stale lightness, and no chroma cap at all, so the opaque tier banded against a - // surface the CSS never paints. One derivation, one place to keep in step with tokens.css. - // `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the - // readable half and can't. + /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from + --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same + colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now + (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the + card. In dark the two still resolve identically; in light the card stays at L88 while the backing + sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted, + carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all. + `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the + readable half and can't. */ applyTiers( { - l: solidifyFloor.l, - c: solidifyFloor.c, + l: num("--glass-opaque-l", dark ? 36.4 : 88), + c: Math.min(tintC * num("--glass-opaque-c-scale", dark ? 1.05 : 0.85), opaqueCMax), h: tintH, }, "-opaque", diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json index 89bf99e..042631e 100644 --- a/public/r/auto-foreground.json +++ b/public/r/auto-foreground.json @@ -11,7 +11,7 @@ "files": [ { "path": "components/auto-foreground.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const solidifyFloor = {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: \"\" for the\n // normal glass-SOLID surface (page + translucent/solid cards), and \"-opaque\" for the solid\n // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars\n // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while\n // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own.\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE[usage]),\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE.ui),\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n // Opaque cards paint the solid --glass-opaque-bg floor — band a second set against it. This IS the\n // solidify floor, undiluted: an opaque card is the one surface with nothing sheer above it, so it is\n // `solidifyFloor` at full strength. Reuse it rather than re-deriving, which is how this drifted — it\n // carried its own `* 0.9` (a multiplier matching neither mode: tokens.css ships 0.85 light / 1.05\n // dark), its own stale lightness, and no chroma cap at all, so the opaque tier banded against a\n // surface the CSS never paints. One derivation, one place to keep in step with tokens.css.\n // `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n // readable half and can't.\n applyTiers(\n {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: \"\" for the\n // normal glass-SOLID surface (page + translucent/solid cards), and \"-opaque\" for the solid\n // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars\n // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while\n // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own.\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE[usage]),\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE.ui),\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", "type": "registry:component", "target": "components/auto-foreground.tsx" } diff --git a/public/r/theme.json b/public/r/theme.json index 837d4d8..64b2c43 100644 --- a/public/r/theme.json +++ b/public/r/theme.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/theme/globals.css", - "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-opaque-bg) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n /* LIGHT-mode wash, raised off this hue's chroma peak to buy body-text contrast — see the note above\n the .dark block below. Dark mode pins the peak value back. */\n --glass-wash-l: 57%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 69.5%; /* light-mode wash — see the .dark block note */\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n/* ── Per-mode wash LIGHTNESS (five presets) ───────────────────────────────────\n Each wash sits at its hue's sRGB chroma PEAK (see the lapis note). For the blue-purple-red arc that\n peak is DARK, which in LIGHT mode pulls the veiled surface down to L84–86 — where body text bottoms\n out at 74.6–76.0 Lc, at or under the 75 floor. (It read as passing until the surface model was fixed\n to composite in sRGB rather than lerp OKLCH coordinates, which had overstated light contrast by up\n to 2.8 Lc.) Raising each LIGHT wash 2–5.5 L lifts the surface enough to clear the floor with margin.\n Alpha is deliberately NOT the lever here: it is per-mode already, and lowering it desaturates.\n --glass-tint-c is not either — glass-tint-switcher.tsx INLINES it (applyTint), which would shadow any\n per-mode split, the same trap documented there for alpha. --glass-wash-l is never inlined, so it is\n the one knob that can differ per mode and still render.\n Above its peak a wash asks more chroma than the gamut holds and the browser clips per channel;\n measured, that costs ≤0.6° of hue and ≤5% chroma here, and check-gamut's overage stays ~1.1× against\n a 4.6× limit. DARK mode already clears the floor everywhere, so each block below pins the original\n peak value rather than inheriting the light one. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n --glass-wash-l: 64%;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n --glass-wash-l: 63%;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n --glass-wash-l: 55%;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n --glass-wash-l: 67%;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 68.5%; /* light-mode wash — same reasoning as the jewels, see the per-mode note above */\n}\n/* The only STATUS preset needing the per-mode wash split: at the shared 63% its light body text reached\n 74.6 Lc, the one outright floor miss in the system. Dark clears it, so pin the original here. Both\n selectors are carried so an error toast tracks the alert. */\n.dark[data-glass-tint=\"destructive\"],\n.dark [data-sonner-toast][data-type=\"error\"] {\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", + "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Solidify floor — the opaque backing UNDER sheer glass ───────────────────\n `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image\n layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on.\n It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE\n CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer\n (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the\n same surface; they only shared a token.\n Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right\n lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is\n not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes\n what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17\n scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with\n no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it\n pins both back to the opaque floor below and nothing changes there. */\n --glass-solidify-l: 92;\n /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather\n than 17 new per-preset numbers, derive from the ones each preset already declares: across the\n palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real\n ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot,\n turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their\n surface chroma; the dark-peaking arc keeps 80–86%. */\n --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65);\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode\n text contrast, and dark already clears the body floor on every preset with margin. Pinning to the\n opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only\n change: a lighter backing under dark glass would wash the whole mode out. */\n --glass-solidify-l: var(--glass-opaque-l);\n --glass-solidify-c-max: var(--glass-opaque-c-max);\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer\n material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque\n CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has\n the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */\n --glass-solidify-bg: oklch(\n calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%)\n min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now\n (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", "type": "registry:style", "target": "app/globals.css" } diff --git a/registry/theme/globals.css b/registry/theme/globals.css index 53ac529..d2fd40a 100644 --- a/registry/theme/globals.css +++ b/registry/theme/globals.css @@ -279,6 +279,28 @@ deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope against its real ceiling rather than trusting this number. */ --glass-opaque-c-max: 0.055; + /* ── Solidify floor — the opaque backing UNDER sheer glass ─────────────────── + `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image + layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on. + It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE + CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer + (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the + same surface; they only shared a token. + Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right + lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is + not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes + what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17 + scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with + no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it + pins both back to the opaque floor below and nothing changes there. */ + --glass-solidify-l: 92; + /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather + than 17 new per-preset numbers, derive from the ones each preset already declares: across the + palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real + ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot, + turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their + surface chroma; the dark-peaking arc keeps 80–86%. */ + --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65); /* ── Chakra — the step-cut material (between opaque and crystal) ───────────── Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets @@ -542,6 +564,12 @@ pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */ /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */ --glass-opaque-c-max: 0.12; + /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode + text contrast, and dark already clears the body floor on every preset with margin. Pinning to the + opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only + change: a lighter backing under dark glass would wash the whole mode out. */ + --glass-solidify-l: var(--glass-opaque-l); + --glass-solidify-c-max: var(--glass-opaque-c-max); /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved). The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28 DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to @@ -918,6 +946,14 @@ --glass-opaque-bg: oklch( calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h) ); + /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer + material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque + CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has + the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */ + --glass-solidify-bg: oklch( + calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%) + min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h) + ); /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03); @@ -1109,7 +1145,9 @@ bands text against the sheer floor, so a more solid surface than it modelled could have cost contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */ - --glass-solidify-fill: color-mix(in oklch, var(--glass-opaque-bg) calc(var(--glass-opacity, 0.7) * 100%), transparent); + /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now + (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */ + --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent); --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill)); /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it. `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers @@ -1647,16 +1685,14 @@ --glass-tint-h: 300; --glass-tint-c: 0.101; --glass-tint-a: 0.15; - /* LIGHT-mode wash, raised off this hue's chroma peak to buy body-text contrast — see the note above - the .dark block below. Dark mode pins the peak value back. */ - --glass-wash-l: 57%; + --glass-wash-l: 55%; --glass-opaque-c-max: 0.065; } [data-glass-tint="rose"] { --glass-tint-h: 8; --glass-tint-c: 0.095; --glass-tint-a: 0.17; - --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ + --glass-wash-l: 64%; --glass-opaque-c-max: 0.062; } [data-glass-tint="amber"] { @@ -1698,7 +1734,7 @@ --glass-tint-h: 342; --glass-tint-c: 0.106; --glass-tint-a: 0.18; - --glass-wash-l: 69.5%; /* light-mode wash — see the .dark block note */ + --glass-wash-l: 67%; --glass-opaque-c-max: 0.079; } [data-glass-tint="lapis"] { @@ -1723,7 +1759,7 @@ --glass-tint-h: 22; --glass-tint-c: 0.094; --glass-tint-a: 0.17; - --glass-wash-l: 67%; /* light-mode wash — see the .dark block note */ + --glass-wash-l: 63%; --glass-opaque-c-max: 0.06; } /* ── Per-mode wash ALPHA ────────────────────────────────────────────────────── @@ -1739,27 +1775,11 @@ presets lose dark-mode text and lapis/amethyst lose light-mode text. The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the values that render — nothing shadows them. */ -/* ── Per-mode wash LIGHTNESS (five presets) ─────────────────────────────────── - Each wash sits at its hue's sRGB chroma PEAK (see the lapis note). For the blue-purple-red arc that - peak is DARK, which in LIGHT mode pulls the veiled surface down to L84–86 — where body text bottoms - out at 74.6–76.0 Lc, at or under the 75 floor. (It read as passing until the surface model was fixed - to composite in sRGB rather than lerp OKLCH coordinates, which had overstated light contrast by up - to 2.8 Lc.) Raising each LIGHT wash 2–5.5 L lifts the surface enough to clear the floor with margin. - Alpha is deliberately NOT the lever here: it is per-mode already, and lowering it desaturates. - --glass-tint-c is not either — glass-tint-switcher.tsx INLINES it (applyTint), which would shadow any - per-mode split, the same trap documented there for alpha. --glass-wash-l is never inlined, so it is - the one knob that can differ per mode and still render. - Above its peak a wash asks more chroma than the gamut holds and the browser clips per channel; - measured, that costs ≤0.6° of hue and ≤5% chroma here, and check-gamut's overage stays ~1.1× against - a 4.6× limit. DARK mode already clears the floor everywhere, so each block below pins the original - peak value rather than inheriting the light one. */ .dark[data-glass-tint="rose"] { --glass-tint-a: 0.49; - --glass-wash-l: 64%; } .dark[data-glass-tint="goldstone"] { --glass-tint-a: 0.5; - --glass-wash-l: 63%; } .dark[data-glass-tint="carnelian"] { --glass-tint-a: 0.45; @@ -1787,11 +1807,9 @@ } .dark[data-glass-tint="amethyst"] { --glass-tint-a: 0.54; - --glass-wash-l: 55%; } .dark[data-glass-tint="tourmaline"] { --glass-tint-a: 0.46; - --glass-wash-l: 67%; } [data-glass-tint="moonstone"] { /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent @@ -1908,13 +1926,6 @@ --glass-tint-h: 25; --glass-tint-c: 0.094; --glass-tint-a: 0.18; - --glass-wash-l: 68.5%; /* light-mode wash — same reasoning as the jewels, see the per-mode note above */ -} -/* The only STATUS preset needing the per-mode wash split: at the shared 63% its light body text reached - 74.6 Lc, the one outright floor miss in the system. Dark clears it, so pin the original here. Both - selectors are carried so an error toast tracks the alert. */ -.dark[data-glass-tint="destructive"], -.dark [data-sonner-toast][data-type="error"] { --glass-wash-l: 63%; } /* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its diff --git a/scripts/check-contrast.mjs b/scripts/check-contrast.mjs index 024b266..61b9c4f 100644 --- a/scripts/check-contrast.mjs +++ b/scripts/check-contrast.mjs @@ -31,7 +31,13 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { apcaContrast, glassSolidSurface, READABLE_USAGE, readableForeground } from "../lib/oklch-utils.ts"; +import { apcaContrast, glassSolidSurface, pickInBand, READABLE_USAGE, themeForeground } from "../lib/oklch-utils.ts"; + +/* Mirrors the ramp AutoForeground builds: the /colors default step count, and the clip that drops the + achromatic ends. Keep in step with components/auto-foreground.tsx. */ +const RAMP_COUNT = 12; +const TONAL_MIN_L = 18; +const TONAL_MAX_L = 97; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const read = (p) => readFileSync(join(ROOT, p), "utf8"); @@ -114,14 +120,37 @@ for (const p of all) { const cMax = resolve(p, mode, vars, "glass-opaque-c-max", dark ? 0.12 : 0.055); const solidA = vars[mode].get("glass-solid-a") ?? 0.65; + /* The backing under sheer glass is --glass-solidify-*, its own surface since the split (tokens.css) + — NOT --glass-opaque-*, which is what an opaque CARD paints. Light lifts it to 92 with a derived + cap; dark pins both back to the opaque floor, so this resolves identically there. */ + const solidifyL = resolve(p, mode, vars, "glass-solidify-l", dark ? opaqueL : 92); + const solidifyCMax = resolve(p, mode, vars, "glass-solidify-c-max", dark ? cMax : cMax * 0.65); + /* The same composition components/auto-foreground.tsx models: the veil/solid floor, the solidify layer (--glass-opacity, 0.7 fallback) over it, then the tint wash on top. */ const surface = glassSolidSurface(dark, { h, c, a }, solidA, washL, washCMult, { - l: opaqueL, - c: Math.min(c * cScale, cMax), + l: solidifyL, + c: Math.min(c * cScale, solidifyCMax), a: 0.7, }); - const fg = readableForeground(surface, { ...READABLE_USAGE.body, hue: h, chroma: 0.15 }); + /* Measure THE TOKEN THAT SHIPS, not a parallel solve. The normal surface does NOT use + readableForeground — that is the `adaptive` path, for the opaque/crystal/chakra floors. Body text on + the page draws from the discrete tonal RAMP via pickInBand, whose darkest surviving step is L20. + The two disagree exactly where it matters: readableForeground solves a continuous lightness and can + always find one that clears the floor, while the ramp either has a step that clears it or does not. + Measured on this tree, four presets sat at 74.9 Lc on the L20 step — just under + AutoForeground's legibility guard, which then discards the tonal ramp and hands back the raw + ramp's extreme, i.e. #000000. This guard reported all four green at 76.5 Lc throughout, because the + continuous solve found a near-black it could reach. Modelling the ramp is what makes "the floor + holds" mean "the emitted colour holds". */ + const base = { l: 60, c: 0.15, h: surface.h }; + const ramp = Array.from({ length: RAMP_COUNT + 1 }, (_, level) => + themeForeground({ palette: "lightness", level, count: RAMP_COUNT, base, dark }), + ).filter((s) => s.l >= TONAL_MIN_L && s.l <= TONAL_MAX_L); + // The show-through margin AutoForeground lifts each band target by (see LC_MARGIN there). + const boost = 12 * (1 - solidA) * (1 - a) * (1 - 0.7); + const band = { ...READABLE_USAGE.body, target: Math.min(READABLE_USAGE.body.target + boost, READABLE_USAGE.body.ceiling) }; + const fg = pickInBand(ramp, surface, band); const lc = Math.abs(apcaContrast(fg, surface)); report.push({ name: p.name, mode, lc, washL, a }); diff --git a/tests/auto-foreground-hue-match.test.tsx b/tests/auto-foreground-hue-match.test.tsx index 13cdfa0..88d7204 100644 --- a/tests/auto-foreground-hue-match.test.tsx +++ b/tests/auto-foreground-hue-match.test.tsx @@ -47,8 +47,10 @@ const normalSurface = (h: number) => 72, 2.5, { - l: 88, - c: Math.min(TINT_C * 0.85, 0.055), + // The SOLIDIFY floor (--glass-solidify-l / -c-max), not the opaque one — the backing under sheer + // glass is its own surface. Light lifts it to 92 with the cap derived as opaque-c-max × 0.65. + l: 92, + c: Math.min(TINT_C * 0.85, 0.055 * 0.65), a: 0.7, }, ); diff --git a/tests/auto-foreground-surface-parity.test.tsx b/tests/auto-foreground-surface-parity.test.tsx index a05ff65..4f9da16 100644 --- a/tests/auto-foreground-surface-parity.test.tsx +++ b/tests/auto-foreground-surface-parity.test.tsx @@ -48,8 +48,11 @@ const surfaces = (dark: boolean): Record => { // here because jsdom resolves --glass-tint-c to its 0 fallback. const opacity = 0.7; const opaqueL = dark ? 36.4 : 88; + /* The backing under SHEER glass is --glass-solidify-l, split from the opaque card floor: light lifts + it to 92, dark pins it back to --glass-opaque-l. Chroma is 0 throughout here because jsdom resolves + --glass-tint-c to its 0 fallback, so the derived cap never binds. */ const solidify = { - l: opaqueL, + l: dark ? opaqueL : 92, c: 0, h, a: opacity, diff --git a/tests/auto-foreground-tonal-ramp.test.tsx b/tests/auto-foreground-tonal-ramp.test.tsx index ea9f49f..89b70ff 100644 --- a/tests/auto-foreground-tonal-ramp.test.tsx +++ b/tests/auto-foreground-tonal-ramp.test.tsx @@ -117,7 +117,13 @@ describe("AutoForeground: light-mode text keeps its hue", () => { it("leaves NEUTRAL themes free to use black", async () => { /* Selenite has chroma 0: its ramp is a grey scale, where black is the darkest TONE rather than a - colour that lost its hue. Clipping there would cap contrast for nothing. */ + colour that lost its hue. Clipping there would cap contrast for nothing. + The surface is PINNED because the bypass is only observable when the `small` band is out of + reach: that is what sends pickInBand past the clip to the extreme, and a clipped ramp would stop + at L20 instead. At the shipped --glass-solidify-l (92) the surface is light enough that the band + is satisfiable at L25, so the solver correctly picks a softer ink and the bypass is invisible — + which says nothing about whether it still exists. L88 is the backing this was written against. */ + root().style.setProperty("--glass-solidify-l", "88"); root().style.setProperty("--glass-tint-h", "250"); root().style.setProperty("--glass-tint-c", "0"); render(); @@ -171,8 +177,10 @@ describe("AutoForeground: light-mode text keeps its hue", () => { root().style.setProperty("--glass-tint-h", "75"); root().style.setProperty("--glass-tint-c", "0.09"); root().style.setProperty("--glass-tint-a", "0.2"); - // Drive the surface into the mid-tone band, where the clipped ramp cannot clear the 75 Lc body floor. - root().style.setProperty("--glass-opaque-l", "60"); + /* Drive the surface into the mid-tone band, where the clipped ramp cannot clear the 75 Lc body floor. + --glass-solidify-l, not --glass-opaque-l: the backing under sheer glass is its own token now, and + --glass-opaque-l moves only the opaque CARD floor (see tokens.css). */ + root().style.setProperty("--glass-solidify-l", "60"); root().style.setProperty("--glass-opacity", "0.9"); render(); await waitFor(() => expect(read("--foreground")).not.toBe("")); From 1ccda480f1b1694a67bcca53cb9ef57872520494 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:59:15 -0700 Subject: [PATCH 07/12] =?UTF-8?q?You're=20right,=20and=20it's=20not=20just?= =?UTF-8?q?=20a=20wording=20slip=20=E2=80=94=20it=20points=20at=20the=20lo?= =?UTF-8?q?osest=20fit=20in=20the=20system.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five materials (`glass`, `frosted`, `crystal`, `chakra`, `opaque`), four tier sets (`""`, `-opaque`, `-crystal`, `-chakra`). Only crystal, chakra and opaque remap `--foreground*` to their own set — **`glass` and `frosted` both fall through to `""`**. The stale claim was `auto-foreground.tsx:391`: *"Run twice"*, from when there were only two sets. It runs four times. Fixed, along with the parity test's docstring. `glass` and `frosted` aren't the same surface. Frosted paints `--glass-frosted-bg` — the same 5-stop sheet lifted by `--glass-frost-boost` (0.1 light / 0.06 dark), so roughly double the alpha. And the paint order is `bg-color → solidify → wash → **sheet**` (the sheet is first in the `background-image` list, so topmost). `glassSolidSurface` stops at the wash — **neither sheet is modeled at all**: | preset | modelled | + glass sheet | + frosted sheet | body Lc: model / glass / frosted | |---|---|---|---|---| | lapis | L87.4 | L88.7 | L89.8 | 79.7 / 82.2 / **84.2** | | goldstone | L86.9 | L88.3 | L89.3 | 77.8 / 80.4 / **82.4** | | rose | L87.1 | L88.5 | L89.5 | 78.1 / 80.6 / **82.7** | So text is banded against a floor darker than what renders — the **safe** direction, and no tier breaches its band ceiling of 90. But it means every glass surface runs 2.2–2.6 Lc over-contrasted and frosted 4.2–4.6, which is part of why light-mode text sits darker than it needs to. I've documented this rather than changed it, because it's a design call and the numbers are all in-band. Two options if you want it tightened: - **Model the sheet in the `""` surface** — fixes glass, but one model can't serve both sheets, so frosted stays loose (just less so). - **Give frosted a fifth tier set** — the complete fix. Needs the new suffix in `applyTiers`, a `[data-material="frosted"]:not(.glass-veil)` remap, and a mean-alpha term for the sheet gradient (same treatment `GLOSS_TOP_A` gets for the crystal gloss). The second is the one that makes the count five-for-five. Worth noting it would make frosted text *lighter*, which is the direction you've wanted all along. Everything still green: 171 tests, lint, typecheck, contrast, gamut, theme, APCA, registry smoke. --- components/auto-foreground.tsx | 103 +++++++++---- public/r/auto-foreground.json | 2 +- scripts/check-contrast.mjs | 34 ++++- tests/auto-foreground-adaptive-clip.test.tsx | 143 ++++++++++++++++++ tests/auto-foreground-surface-parity.test.tsx | 4 + 5 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 tests/auto-foreground-adaptive-clip.test.tsx diff --git a/components/auto-foreground.tsx b/components/auto-foreground.tsx index c68ebdf..a712fc6 100644 --- a/components/auto-foreground.tsx +++ b/components/auto-foreground.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { apcaContrast, + clampToGamut, compositeSurface, formatOklch, glassSolidSurface, @@ -387,11 +388,18 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L); const ramp = base.c > 0 && tonal.length ? tonal : rawRamp; const iconHue = storedFg.iconHue; - // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: "" for the - // normal glass-SOLID surface (page + translucent/solid cards), and "-opaque" for the solid - // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars - // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while - // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own. + /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once + per modeled surface: "" (the normal glass-SOLID floor), "-opaque", "-crystal", "-chakra". Each + material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque + floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one + global foreground cannot do both. + FIVE materials share those four sets: `glass` and `frosted` both fall through to "". They are not + quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by + --glass-frost-boost (0.1 light / 0.06 dark) — and the "" model deliberately stops at the wash, so + NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which + is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on + frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving + it a fifth set would tighten it. */ const applyTiers = ( surface: { l: number; @@ -434,6 +442,58 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor const full = pickInBand(rawRamp, surface, band); return lc(full) > lc(best) ? full : best; }; + /* THE ADAPTIVE TWIN OF pickTonal. readableForeground aims at the band target and, when the target + sits beyond what the surface can give, returns the most contrast AVAILABLE — which is the + lightness extreme, exactly where the gamut annihilates the requested chroma and tinted text + renders #000 / #fff. That is the same failure the tonal ramp clip exists to prevent, on the + three surfaces the ramp cannot serve (they need a polarity the ramp does not span). + It fires in light mode by construction: an L88 opaque floor tops out at 81.1–84.3 Lc while the + opaque tiers aim at target + LC_AIM_KNOWN, so the aim is unreachable and every jewel's + --foreground-opaque solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc — the + same near-nothing the ramp clip trades, for the same reason (near the extreme, luminance barely + moves with lightness) — and returns a genuinely tinted ink. Dark is untouched: those solves land + at L92–93, comfortably inside the clip. + minChroma is NOT the lever here despite documenting this case: its search bisects toward the + BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the + dark-peaking arc, so the branch bails straight back to the extreme. Measured, minChroma 0.08 + still renders lapis/goldstone/rose black. It stays on for the accent path, which it was built + for and where the background IS able to hold the chroma. */ + const readableTonal = ( + band: { + floor: number; + target: number; + ceiling: number; + }, + hue: number, + chroma: number, + ) => { + const solved = readableForeground(surface, { + floor: band.floor, + target: band.target, + ceiling: band.ceiling, + hue, + chroma, + // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the + // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve + // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other + // theme passes 0 → behavior unchanged. + minChroma: huelessAccent ? 0.08 : 0, + }); + // NEUTRAL themes keep the full range: there black and white are the genuine ends of a grey + // scale, not colours that lost their hue — same exemption the ramp clip makes. + if (chroma <= 0) return solved; + const clippedL = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L); + if (clippedL === solved.l) return solved; + const clipped = clampToGamut({ + l: clippedL, + c: chroma, + h: hue, + }); + /* Guard LEGIBILITY, never the aspiration — the same line pickTonal holds. Every measured case + clears it with room (80.0–83.3 against a floor of 75), but a re-based ramp or a shallower + surface could not, and hue must never be bought with readability. */ + return Math.abs(apcaContrast(clipped, surface)) >= LEGIBLE_FLOOR ? clipped : solved; + }; // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the @@ -442,22 +502,15 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor const band = boost(rawBand); return formatOklch( adaptive - ? readableForeground(surface, { - floor: band.floor, - target: band.target, - ceiling: band.ceiling, + ? readableTonal( + band, // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent. // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each // adaptive material composites a different stack, so each gets ink at its own angle. - hue: huelessAccent ? accentH : surface.h, - chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0, - // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the - // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve - // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other - // theme passes 0 → behavior unchanged. - minChroma: huelessAccent ? 0.08 : 0, - }) + huelessAccent ? accentH : surface.h, + huelessAccent ? accentC : tintC > 0 ? cfgC : 0, + ) : pickTonal(band), ); }; @@ -479,11 +532,9 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor choice == null ? tier(READABLE_USAGE[usage]) : formatOklch( - readableForeground(surface, { - ...boost(READABLE_USAGE[usage]), - hue: typeof choice === "string" ? harmonicHue(fgHarmonyH, choice) : choice, - chroma: 0.15, - }), + // Clipped like every other solve: a PINNED hue is the one case where collapsing to the + // achromatic extreme is most obviously wrong — the whole point of pinning is to see it. + readableTonal(boost(READABLE_USAGE[usage]), typeof choice === "string" ? harmonicHue(fgHarmonyH, choice) : choice, 0.15), ); root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue("large", storedFg.softHue)); // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90, @@ -504,13 +555,7 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor const iconH = typeof iconHue === "string" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === "number" ? iconHue : surface.h; root.style.setProperty( `--foreground-ui${suffix}`, - formatOklch( - readableForeground(surface, { - ...boost(READABLE_USAGE.ui), - hue: iconH, - chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0, - }), - ), + formatOklch(readableTonal(boost(READABLE_USAGE.ui), iconH, iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0)), ); }; diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json index 042631e..194de79 100644 --- a/public/r/auto-foreground.json +++ b/public/r/auto-foreground.json @@ -11,7 +11,7 @@ "files": [ { "path": "components/auto-foreground.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n // Set the full foreground tier set against a given SURFACE, under a var suffix. Run twice: \"\" for the\n // normal glass-SOLID surface (page + translucent/solid cards), and \"-opaque\" for the solid\n // --glass-opaque-bg floor. The opaque re-skin in globals.css remaps --foreground* → the -opaque vars\n // inside opaque cards, so a LIGHT opaque floor (e.g. dark-mode moonstone cream) gets DARK card text while\n // the dark page keeps light text — one global foreground can't do both, so opaque cards get their own.\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE[usage]),\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableForeground(surface, {\n ...boost(READABLE_USAGE.ui),\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n clampToGamut,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n /* THE ADAPTIVE TWIN OF pickTonal. readableForeground aims at the band target and, when the target\n sits beyond what the surface can give, returns the most contrast AVAILABLE — which is the\n lightness extreme, exactly where the gamut annihilates the requested chroma and tinted text\n renders #000 / #fff. That is the same failure the tonal ramp clip exists to prevent, on the\n three surfaces the ramp cannot serve (they need a polarity the ramp does not span).\n It fires in light mode by construction: an L88 opaque floor tops out at 81.1–84.3 Lc while the\n opaque tiers aim at target + LC_AIM_KNOWN, so the aim is unreachable and every jewel's\n --foreground-opaque solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc — the\n same near-nothing the ramp clip trades, for the same reason (near the extreme, luminance barely\n moves with lightness) — and returns a genuinely tinted ink. Dark is untouched: those solves land\n at L92–93, comfortably inside the clip.\n minChroma is NOT the lever here despite documenting this case: its search bisects toward the\n BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the\n dark-peaking arc, so the branch bails straight back to the extreme. Measured, minChroma 0.08\n still renders lapis/goldstone/rose black. It stays on for the accent path, which it was built\n for and where the background IS able to hold the chroma. */\n const readableTonal = (\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n hue: number,\n chroma: number,\n ) => {\n const solved = readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n hue,\n chroma,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n });\n // NEUTRAL themes keep the full range: there black and white are the genuine ends of a grey\n // scale, not colours that lost their hue — same exemption the ramp clip makes.\n if (chroma <= 0) return solved;\n const clippedL = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L);\n if (clippedL === solved.l) return solved;\n const clipped = clampToGamut({\n l: clippedL,\n c: chroma,\n h: hue,\n });\n /* Guard LEGIBILITY, never the aspiration — the same line pickTonal holds. Every measured case\n clears it with room (80.0–83.3 against a floor of 75), but a re-based ramp or a shallower\n surface could not, and hue must never be bought with readability. */\n return Math.abs(apcaContrast(clipped, surface)) >= LEGIBLE_FLOOR ? clipped : solved;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(\n band,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n huelessAccent ? accentH : surface.h,\n huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n )\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(boost(READABLE_USAGE[usage]), typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice, 0.15),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(readableTonal(boost(READABLE_USAGE.ui), iconH, iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0)),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", "type": "registry:component", "target": "components/auto-foreground.tsx" } diff --git a/scripts/check-contrast.mjs b/scripts/check-contrast.mjs index 61b9c4f..92c11ea 100644 --- a/scripts/check-contrast.mjs +++ b/scripts/check-contrast.mjs @@ -31,11 +31,13 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { apcaContrast, glassSolidSurface, pickInBand, READABLE_USAGE, themeForeground } from "../lib/oklch-utils.ts"; +import { apcaContrast, clampToGamut, glassSolidSurface, pickInBand, READABLE_USAGE, readableForeground, themeForeground } from "../lib/oklch-utils.ts"; /* Mirrors the ramp AutoForeground builds: the /colors default step count, and the clip that drops the achromatic ends. Keep in step with components/auto-foreground.tsx. */ const RAMP_COUNT = 12; +const RAMP_CHROMA = 0.15; // the /colors ramp default AutoForeground solves adaptive tiers at +const LC_AIM_KNOWN = 4; // the opaque tier's baseline aim above target — mirrors auto-foreground.tsx const TONAL_MIN_L = 18; const TONAL_MAX_L = 97; @@ -154,6 +156,36 @@ for (const p of all) { const lc = Math.abs(apcaContrast(fg, surface)); report.push({ name: p.name, mode, lc, washL, a }); + /* THE OPAQUE TIER, which this guard did not cover at all — and which was silently shipping #000000 + on every jewel in light. That surface is solved by readableForeground, not the ramp, and it aims + at target + LC_AIM_KNOWN; an L88 floor tops out at 81.1–84.3 Lc, so the aim is unreachable and the + solve pins to the lightness extreme where the gamut annihilates chroma. AutoForeground clips it to + the same TONAL bounds as the ramp; assert the clip held, i.e. that the emitted ink still carries + the theme's colour rather than having collapsed to black or white. Neutral scopes are exempt for + the same reason the ramp clip exempts them: there the extremes are genuine ends of a grey scale. */ + const opaqueSurface = { l: opaqueL, c: Math.min(c * cScale, cMax), h }; + const opaqueBand = { ...READABLE_USAGE.body, target: Math.min(READABLE_USAGE.body.target + LC_AIM_KNOWN, READABLE_USAGE.body.ceiling) }; + const opaqueSolved = readableForeground(opaqueSurface, { ...opaqueBand, hue: opaqueSurface.h, chroma: RAMP_CHROMA }); + const opaqueL2 = Math.min(Math.max(opaqueSolved.l, TONAL_MIN_L), TONAL_MAX_L); + const opaqueInk = opaqueL2 === opaqueSolved.l ? opaqueSolved : clampToGamut({ l: opaqueL2, c: RAMP_CHROMA, h: opaqueSurface.h }); + const opaqueLc = Math.abs(apcaContrast(opaqueInk, opaqueSurface)); + /* Assert LIGHTNESS, not chroma. At L≈0 the shared gamut test is eps-tolerant and reports PHANTOM + chroma — #000000 comes back carrying c 0.067 at the warm hues — so a chroma assertion silently + passes the exact colour it is meant to catch (lib/oklch-utils documents this inside + readableForeground's minChroma branch, which rejects it with a tighter eps). The clip bounds are + the honest test: inside them the colour renders tinted, outside it does not. */ + if (c > 0 && (opaqueInk.l < TONAL_MIN_L || opaqueInk.l > TONAL_MAX_L)) { + failures.push( + `[opaque] ${p.name} (${mode}) — --foreground-opaque solved to L${opaqueInk.l.toFixed(1)}, past the tonal clip ` + + `(${TONAL_MIN_L}–${TONAL_MAX_L}), on an L${opaqueL} floor whose reach is ${opaqueLc.toFixed(1)} Lc. ` + + `That is the achromatic extreme — the clip should have caught it.`, + ); + } else if (opaqueLc < BODY_FLOOR) { + failures.push( + `[opaque] ${p.name} (${mode}) — opaque-card body text reaches only ${opaqueLc.toFixed(1)} Lc on its L${opaqueL} floor, under the ${BODY_FLOOR} floor.`, + ); + } + const known = KNOWN.get(`${p.name}|${mode}`); if (known) { /* Pinned: must not get WORSE than the value recorded when it was accepted. If it improves past the diff --git a/tests/auto-foreground-adaptive-clip.test.tsx b/tests/auto-foreground-adaptive-clip.test.tsx new file mode 100644 index 0000000..da8265b --- /dev/null +++ b/tests/auto-foreground-adaptive-clip.test.tsx @@ -0,0 +1,143 @@ +/** + * The ADAPTIVE tiers must keep their hue too, not just the ramp-drawn ones. + * + * `--foreground-opaque` / `-crystal` / `-chakra` are solved by readableForeground rather than picked from + * the tonal ramp, because those floors can need a polarity the ramp does not span (a LIGHT opaque card on + * a dark page needs DARK text; the ramp only runs white→base in dark mode). + * + * readableForeground aims at the band target and, when the target sits beyond what the surface can give, + * returns the most contrast AVAILABLE — which is the lightness extreme, exactly where sRGB has no gamut + * volume and the requested chroma is annihilated. In light mode that is not an edge case, it is the + * default: an L88 opaque floor tops out at 81.1–84.3 Lc while the opaque tiers aim at target + + * LC_AIM_KNOWN, so the aim is unreachable and every jewel's --foreground-opaque solved to pure #000000. + * + * The fix mirrors the tonal ramp clip (TONAL_MIN_L / TONAL_MAX_L) with the same legibility guard: give up + * the ~1 Lc that the last few points of lightness are worth, and keep the hue. + * + * These assert on CHROMA, not lightness. A tier that is merely dark is fine — the failure mode is a tier + * that has lost its colour entirely, and only chroma distinguishes those two. + */ +import { render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AutoForeground } from "@/components/auto-foreground"; +import { type OklchColor, parseOklch } from "@/lib/oklch-utils"; + +const root = () => document.documentElement; +const read = (name: string) => root().style.getPropertyValue(name).trim(); +const fg = (name: string): OklchColor => { + const c = parseOklch(read(name)); + if (!c) throw new Error(`${name} is not oklch: ${read(name) || "(empty)"}`); + return c; +}; + +/** The three tier sets solved by readableForeground rather than the ramp. */ +const ADAPTIVE = [ + "-opaque", + "-crystal", + "-chakra", +]; + +/** Mount carrying a real tint, as a preset does. jsdom resolves --glass-tint-c to 0 otherwise. */ +async function mountTinted(hue: number, dark = false) { + if (dark) root().classList.add("dark"); + root().style.setProperty("--glass-tint-h", String(hue)); + root().style.setProperty("--glass-tint-c", "0.09"); + root().style.setProperty("--glass-tint-a", "0.15"); + render(); + await waitFor(() => expect(read("--foreground-opaque")).not.toBe("")); +} + +beforeEach(() => { + localStorage.clear(); + root().className = ""; + root().removeAttribute("style"); +}); + +afterEach(() => { + localStorage.clear(); + root().className = ""; + root().removeAttribute("style"); +}); + +describe("AutoForeground: adaptive tiers keep their hue", () => { + it.each([ + [ + "rose-ish", + 8, + ], + [ + "goldstone-ish", + 22, + ], + [ + "warm", + 75, + ], + [ + "green", + 145, + ], + [ + "blue", + 268, + ], + ])("never collapses an adaptive tier to black on a %s tint", async (_label, hue) => { + await mountTinted(hue); + + for (const suffix of ADAPTIVE) { + const c = fg(`--foreground${suffix}`); + expect(c.c, `--foreground${suffix} is ${read(`--foreground${suffix}`)} — chroma annihilated`).toBeGreaterThan(0.01); + expect(c.l, `--foreground${suffix} is L${c.l.toFixed(1)} — below the tonal clip`).toBeGreaterThanOrEqual(18); + } + }); + + it("holds the clip across every adaptive tier, not just the body one", async () => { + /* The size tiers and the icon tier solve separately (tierAtHue / --foreground-ui). The `small` band + floors at Lc 90, which NO light surface reaches, so those are the most reach-limited of the set and + the likeliest to pin to the extreme. */ + await mountTinted(268); + + for (const suffix of ADAPTIVE) { + for (const tier of [ + "--muted-foreground", + "--foreground-soft", + "--foreground-strong", + "--foreground-ui", + ]) { + /* Lightness AND chroma. Chroma alone is too loose to discriminate here: clampToGamut still + returns a sliver of it at L≈0, enough to clear a bare `> 0.005` while the colour is black to + the eye. The clip bound is what actually pins the behaviour. */ + const c = fg(`${tier}${suffix}`); + expect(c.l, `${tier}${suffix} is L${c.l.toFixed(1)} — below the tonal clip`).toBeGreaterThanOrEqual(18); + expect(c.c, `${tier}${suffix} lost its chroma`).toBeGreaterThan(0.005); + } + } + }); + + it("leaves NEUTRAL themes free to reach the extremes", async () => { + /* Chroma 0 is a grey scale, where black and white are the genuine ends of the ramp rather than + colours that lost their hue — the same exemption the tonal ramp clip makes. Asserting the bypass + exists at all: a blanket clamp would have pinned these to L18 too. */ + root().style.setProperty("--glass-tint-h", "250"); + root().style.setProperty("--glass-tint-c", "0"); + render(); + await waitFor(() => expect(read("--foreground-opaque")).not.toBe("")); + + for (const suffix of ADAPTIVE) { + expect(fg(`--foreground${suffix}`).c, `neutral --foreground${suffix} invented a hue`).toBe(0); + } + }); + + it("leaves the DARK solves alone — they already land inside the clip", async () => { + /* Dark opaque solves to L92–93 against an L36.4 floor, comfortably inside TONAL_MAX_L, so the clip + must be a no-op there. This is the control: if it ever starts biting in dark, the bounds are wrong + rather than the surfaces. */ + await mountTinted(268, true); + + for (const suffix of ADAPTIVE) { + const c = fg(`--foreground${suffix}`); + expect(c.l, `--foreground${suffix} is L${c.l.toFixed(1)}`).toBeLessThanOrEqual(97); + expect(c.c).toBeGreaterThan(0.005); + } + }); +}); diff --git a/tests/auto-foreground-surface-parity.test.tsx b/tests/auto-foreground-surface-parity.test.tsx index 4f9da16..bd92ac1 100644 --- a/tests/auto-foreground-surface-parity.test.tsx +++ b/tests/auto-foreground-surface-parity.test.tsx @@ -12,6 +12,10 @@ * These tests measure the emitted tokens the way a reader sees them — |Lc| of the solved foreground * against the surface it is painted on — and assert the four surfaces stay clustered. * + * FOUR sets, FIVE materials: `glass` and `frosted` both fall through to the base "" set, so there is no + * `-frosted` surface to measure here. That is a real (if conservative) approximation rather than an + * oversight in this file — see the applyTiers comment in components/auto-foreground.tsx. + * * jsdom resolves no stylesheets, so every `num(name, fallback)` in the component takes its fallback; * the surface models below mirror those same fallbacks (tint chroma/alpha 0, hue 255, solid-a 0.65). */ From 1b912be7e2df009e91f04704a38c0268014595cf Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:30:43 -0700 Subject: [PATCH 08/12] updated registry and checks --- app/page.tsx | 2 +- components/auto-foreground.tsx | 13 +- public/r/auto-foreground.json | 2 +- tests/auto-foreground-hue-match.test.tsx | 33 ++++ tests/auto-foreground-layer-caps.test.tsx | 144 ++++++++++++++++++ tests/auto-foreground-surface-parity.test.tsx | 83 +++++++--- 6 files changed, 246 insertions(+), 31 deletions(-) create mode 100644 tests/auto-foreground-layer-caps.test.tsx diff --git a/app/page.tsx b/app/page.tsx index 0f9e08a..137da9f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -401,7 +401,7 @@ export default function Home() { {/* ───────────────── Variants ───────────────── */}
-

Four distinct materials

+

Five distinct materials

Every component composes the Glass, Frosted, Crystal, Chakra, and Opaque materials.

diff --git a/components/auto-foreground.tsx b/components/auto-foreground.tsx index a712fc6..dec14a9 100644 --- a/components/auto-foreground.tsx +++ b/components/auto-foreground.tsx @@ -632,9 +632,9 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor // Chakra cards: content sits on the translucent body, banded against the whole stack it paints. // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and // pair a highlight against an ink on opposite sides, so they contribute nothing where text - // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any - // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body - // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text. + // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same + // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason + // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half. { const bodyA = num("--glass-chakra-a", dark ? 0.58 : 0.62); /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light @@ -644,7 +644,12 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */ - const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1); + /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the + backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the + gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an + UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known + than the model claimed. */ + const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1); applyTiers( compositeSurface( { diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json index 194de79..59eb204 100644 --- a/public/r/auto-foreground.json +++ b/public/r/auto-foreground.json @@ -11,7 +11,7 @@ "files": [ { "path": "components/auto-foreground.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n clampToGamut,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n /* THE ADAPTIVE TWIN OF pickTonal. readableForeground aims at the band target and, when the target\n sits beyond what the surface can give, returns the most contrast AVAILABLE — which is the\n lightness extreme, exactly where the gamut annihilates the requested chroma and tinted text\n renders #000 / #fff. That is the same failure the tonal ramp clip exists to prevent, on the\n three surfaces the ramp cannot serve (they need a polarity the ramp does not span).\n It fires in light mode by construction: an L88 opaque floor tops out at 81.1–84.3 Lc while the\n opaque tiers aim at target + LC_AIM_KNOWN, so the aim is unreachable and every jewel's\n --foreground-opaque solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc — the\n same near-nothing the ramp clip trades, for the same reason (near the extreme, luminance barely\n moves with lightness) — and returns a genuinely tinted ink. Dark is untouched: those solves land\n at L92–93, comfortably inside the clip.\n minChroma is NOT the lever here despite documenting this case: its search bisects toward the\n BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the\n dark-peaking arc, so the branch bails straight back to the extreme. Measured, minChroma 0.08\n still renders lapis/goldstone/rose black. It stays on for the accent path, which it was built\n for and where the background IS able to hold the chroma. */\n const readableTonal = (\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n hue: number,\n chroma: number,\n ) => {\n const solved = readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n hue,\n chroma,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n });\n // NEUTRAL themes keep the full range: there black and white are the genuine ends of a grey\n // scale, not colours that lost their hue — same exemption the ramp clip makes.\n if (chroma <= 0) return solved;\n const clippedL = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L);\n if (clippedL === solved.l) return solved;\n const clipped = clampToGamut({\n l: clippedL,\n c: chroma,\n h: hue,\n });\n /* Guard LEGIBILITY, never the aspiration — the same line pickTonal holds. Every measured case\n clears it with room (80.0–83.3 against a floor of 75), but a re-based ramp or a shallower\n surface could not, and hue must never be bought with readability. */\n return Math.abs(apcaContrast(clipped, surface)) >= LEGIBLE_FLOOR ? clipped : solved;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(\n band,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n huelessAccent ? accentH : surface.h,\n huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n )\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(boost(READABLE_USAGE[usage]), typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice, 0.15),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(readableTonal(boost(READABLE_USAGE.ui), iconH, iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0)),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glassOpacity), the largest of any\n // tier here, so this gets the full margin. `adaptive` for the same reason opaque is: an L88 body\n // can sit on a dark page, and the theme ramp only spans the readable half so it cannot make dark text.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n clampToGamut,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n /* THE ADAPTIVE TWIN OF pickTonal. readableForeground aims at the band target and, when the target\n sits beyond what the surface can give, returns the most contrast AVAILABLE — which is the\n lightness extreme, exactly where the gamut annihilates the requested chroma and tinted text\n renders #000 / #fff. That is the same failure the tonal ramp clip exists to prevent, on the\n three surfaces the ramp cannot serve (they need a polarity the ramp does not span).\n It fires in light mode by construction: an L88 opaque floor tops out at 81.1–84.3 Lc while the\n opaque tiers aim at target + LC_AIM_KNOWN, so the aim is unreachable and every jewel's\n --foreground-opaque solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc — the\n same near-nothing the ramp clip trades, for the same reason (near the extreme, luminance barely\n moves with lightness) — and returns a genuinely tinted ink. Dark is untouched: those solves land\n at L92–93, comfortably inside the clip.\n minChroma is NOT the lever here despite documenting this case: its search bisects toward the\n BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the\n dark-peaking arc, so the branch bails straight back to the extreme. Measured, minChroma 0.08\n still renders lapis/goldstone/rose black. It stays on for the accent path, which it was built\n for and where the background IS able to hold the chroma. */\n const readableTonal = (\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n hue: number,\n chroma: number,\n ) => {\n const solved = readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n hue,\n chroma,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n });\n // NEUTRAL themes keep the full range: there black and white are the genuine ends of a grey\n // scale, not colours that lost their hue — same exemption the ramp clip makes.\n if (chroma <= 0) return solved;\n const clippedL = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L);\n if (clippedL === solved.l) return solved;\n const clipped = clampToGamut({\n l: clippedL,\n c: chroma,\n h: hue,\n });\n /* Guard LEGIBILITY, never the aspiration — the same line pickTonal holds. Every measured case\n clears it with room (80.0–83.3 against a floor of 75), but a re-based ramp or a shallower\n surface could not, and hue must never be bought with readability. */\n return Math.abs(apcaContrast(clipped, surface)) >= LEGIBLE_FLOOR ? clipped : solved;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(\n band,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n huelessAccent ? accentH : surface.h,\n huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n )\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(boost(READABLE_USAGE[usage]), typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice, 0.15),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(readableTonal(boost(READABLE_USAGE.ui), iconH, iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0)),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same\n // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason\n // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the\n backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the\n gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an\n UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known\n than the model claimed. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", "type": "registry:component", "target": "components/auto-foreground.tsx" } diff --git a/tests/auto-foreground-hue-match.test.tsx b/tests/auto-foreground-hue-match.test.tsx index 88d7204..6292f5f 100644 --- a/tests/auto-foreground-hue-match.test.tsx +++ b/tests/auto-foreground-hue-match.test.tsx @@ -127,6 +127,39 @@ describe("AutoForeground: ink follows its surface's hue", () => { expect(Math.abs(hueGap(fg("--foreground-opaque").h, hue))).toBeLessThan(1); }); + it("puts --foreground-ui at the composited surface hue too", async () => { + /* Icons take the same rule as text when iconHue is null: follow THIS surface's composited hue, so an + icon sits at the same angle as the material behind it. It solves on a separate path from the text + tiers (its own ui band, its own hue resolution), so passing there says nothing about here. */ + for (const hue of [ + 8, + 22, + 268, + ]) { + root().removeAttribute("style"); + await mountTinted(hue); + const surface = normalSurface(hue); + expect(Math.abs(hueGap(fg("--foreground-ui").h, surface.h)), `--foreground-ui on h${hue}`).toBeLessThan(1); + } + }); + + it("drifts the icon tiers off the tint hue on the crystal and chakra stacks", async () => { + /* The discriminating half: --foreground-ui* must track its own material's composite, not the raw + token. h8 drifts ~4.7°, so a tier still sitting at 8 would fail this. */ + const hue = 8; + await mountTinted(hue); + + for (const tier of [ + "--foreground-ui", + "--foreground-ui-crystal", + "--foreground-ui-chakra", + ]) { + expect(Math.abs(hueGap(fg(tier).h, hue)), `${tier} sat back at the tint hue`).toBeGreaterThan(1); + } + // Opaque is the control here as well: nothing composites over it, so its icon stays at the tint hue. + expect(Math.abs(hueGap(fg("--foreground-ui-opaque").h, hue))).toBeLessThan(1); + }); + it("matches ink to surface on the crystal and chakra stacks too", async () => { /* Both composite their own layer stacks, so each lands at its own angle — and each one's text should follow ITS surface, not the page's. They differ from the normal surface by well under a JND, so the diff --git a/tests/auto-foreground-layer-caps.test.tsx b/tests/auto-foreground-layer-caps.test.tsx new file mode 100644 index 0000000..391d9db --- /dev/null +++ b/tests/auto-foreground-layer-caps.test.tsx @@ -0,0 +1,144 @@ +/** + * TST-1-2 — the per-layer chroma CAPS in the crystal / chakra stacks, exercised with a real tint. + * + * Each sheer layer clamps the theme's chroma by a different formula over the same `--glass-tint-c`, and + * they are easy to conflate: + * - crystal floor `--glass-tint-c-hi × 0.6` — the NEAR-WHITE budget, min(tint-c, 0.017) + * - gloss ink `min(tint-c × --glass-gloss-tint, --glass-gloss-c-max)` — multiply THEN cap + * - chakra body `min(tint-c, --glass-chakra-c-max)` — cap the raw chroma, no multiplier + * - solidify floor `min(tint-c × --glass-opaque-c-scale, --glass-solidify-c-max)` — scale THEN cap + * The surface-parity suite cannot see any of them: it asserts Lc SPREAD across surfaces, and a cap + * regression moves every surface together by far less than that threshold. + * + * What each cap is actually worth on the composited surface (measured, tint-c 0.09, light): + * gloss capped 0.013 vs uncapped 0.3825 → L90.50 → L87.03, chroma 0.0425 → 0.0794 (large) + * crystal capped 0.0102 vs uncapped 0.054 → L90.50 → L90.46 (diluted) + * chakra capped 0.055 vs uncapped 0.09 → L89.57 → L89.47 (diluted) + * + * So the gloss cap is behaviourally observable and the other two are not — the 70%-opaque solidify layer + * above them leaves only ~6% of their chroma in the mix. These pin the gloss cap through the emitted + * token, and pin the other two at `--glass-opacity: 0`, where the solidify layer is absent and their + * contribution is at its largest. That second pair is a model-fidelity assertion, not a perceptual one; + * saying so here is better than a tolerance loose enough to pass either way. + */ +import { render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AutoForeground } from "@/components/auto-foreground"; +import { apcaContrast, compositeSurface, type OklchColor, parseOklch } from "@/lib/oklch-utils"; + +const root = () => document.documentElement; +const read = (name: string) => root().style.getPropertyValue(name).trim(); +const fg = (name: string): OklchColor => { + const c = parseOklch(read(name)); + if (!c) throw new Error(`${name} is not oklch: ${read(name) || "(empty)"}`); + return c; +}; +const lcOn = (token: string, surface: OklchColor) => Math.abs(apcaContrast(fg(token), surface)); + +const H = 255; +const TINT_C = 0.09; // exceeds every light cap, so all four bind +const TINT_A = 0.15; + +/** The correctly-capped light stacks, mirroring engine.css / tokens.css. */ +const stacks = (opacity: number) => { + const page = { + l: 95, + c: 0, + h: H, + }; + const solidify = { + l: 92, + c: Math.min(TINT_C * 0.85, 0.055 * 0.65), + h: H, + a: opacity, + }; + const wash = { + l: 72, + c: TINT_C * 2.5, + h: H, + a: TINT_A, + }; + const gloss = { + l: 97, + c: Math.min(TINT_C * 4.25, 0.013), + h: H, + a: 0.2, + }; + return { + crystal: compositeSurface(page, [ + { + l: 96, + c: Math.min(TINT_C, 0.017) * 0.6, + h: H, + a: 0.3, + }, + solidify, + wash, + gloss, + ]), + chakra: compositeSurface(page, [ + { + l: 88, + c: Math.min(TINT_C, 0.055), + h: H, + a: 0.62, + }, + solidify, + wash, + gloss, + ]), + }; +}; + +async function mount(opacity?: number) { + root().style.setProperty("--glass-tint-h", String(H)); + root().style.setProperty("--glass-tint-c", String(TINT_C)); + root().style.setProperty("--glass-tint-a", String(TINT_A)); + if (opacity !== undefined) root().style.setProperty("--glass-opacity", String(opacity)); + render(); + await waitFor(() => expect(read("--foreground-crystal")).not.toBe("")); +} + +beforeEach(() => { + localStorage.clear(); + root().className = ""; + root().removeAttribute("style"); +}); + +afterEach(() => { + localStorage.clear(); + root().className = ""; + root().removeAttribute("style"); +}); + +/* The band aim each adaptive tier solves for: the body target lifted by the show-through margin + (LC_MARGIN × the backdrop's surviving weight). Asserting the emitted ink hits THIS on the correctly- + capped mirror is what makes the test discriminating — it is a statement that the component modelled the + same surface this file does. A wrong cap gives a different surface, so the ink lands off its aim when + measured here, with no magic constant to keep in step. */ +const LC_MARGIN = 12; +const aim = (surviving: number) => Math.min(80 + LC_MARGIN * surviving, 90); +const crystalAim = (opacity: number) => aim((1 - 0.3) * (1 - TINT_A) * (1 - 0.2) * (1 - opacity)); +const chakraAim = (opacity: number) => aim((1 - 0.62) * (1 - TINT_A) * (1 - 0.2) * (1 - opacity)); + +describe("AutoForeground: sheer-layer chroma caps", () => { + it("caps the GLOSS ink at --glass-gloss-c-max, not tint-c × --glass-gloss-tint", async () => { + /* The cap that matters: 0.09 × 4.25 = 0.3825 asked against a 0.013 ceiling, so leaving it off makes + the gloss ~29× too colourful and drags the crystal surface 3.5 L darker. Banding against that wrong + surface moves the emitted ink far past this tolerance. */ + await mount(); + const s = stacks(0.7); + expect(lcOn("--foreground-crystal", s.crystal), "crystal ink is off its aim — the gloss cap moved").toBeCloseTo(crystalAim(0.7), 1); + expect(lcOn("--foreground-chakra", s.chakra), "chakra ink is off its aim — the gloss cap moved").toBeCloseTo(chakraAim(0.7), 1); + }); + + it("caps the crystal floor at --glass-tint-c-hi and the chakra body at --glass-chakra-c-max", async () => { + /* Pinned with the solidify layer OFF (--glass-opacity: 0, the documented sheer default), where these + two caps are no longer diluted to ~6% of their value by a 70%-opaque layer painted over them. Even + here the margin is thin — this is a model-fidelity pin, not a perceptual one. */ + await mount(0); + const s = stacks(0); + expect(lcOn("--foreground-crystal", s.crystal), "crystal ink is off its aim — the --glass-tint-c-hi cap moved").toBeCloseTo(crystalAim(0), 1); + expect(lcOn("--foreground-chakra", s.chakra), "chakra ink is off its aim — the --glass-chakra-c-max cap moved").toBeCloseTo(chakraAim(0), 1); + }); +}); diff --git a/tests/auto-foreground-surface-parity.test.tsx b/tests/auto-foreground-surface-parity.test.tsx index bd92ac1..14d8e97 100644 --- a/tests/auto-foreground-surface-parity.test.tsx +++ b/tests/auto-foreground-surface-parity.test.tsx @@ -41,23 +41,37 @@ const lcOn = (token: string, surface: OklchColor) => Math.abs(apcaContrast(fg(to * The four surface models, mirroring the component's jsdom-fallback inputs exactly. Kept in step with * the `applyTiers()` call sites in components/auto-foreground.tsx. */ +const H = 255; +/* A REAL tint, set inline the way a preset does. These used to be left at jsdom's 0 fallback, which made + every layer achromatic and silently skipped the whole cap system: --glass-tint-c-hi on the crystal + floor, the derived --glass-solidify-c-max, --glass-chakra-c-max, and the gloss ink's own + --glass-gloss-c-max. Those caps are the part most likely to drift, since each is a different formula + over the same tint chroma. 0.09 exceeds every light cap, so all four of them bind. */ +const TINT_C = 0.09; +const TINT_A = 0.15; + const surfaces = (dark: boolean): Record => { - const h = 255; + const h = H; const washL = dark ? 58 : 72; const crysA = dark ? 0.1 : 0.3; const glossL = dark ? 66 : 97; const GLOSS_TOP_A = 0.2; - // The solidify floor (--glass-opacity, 0.7 fallback) paints over every SHEER material's background - // colour and under its image stack, so each sheer model composites toward it. Chroma is 0 throughout - // here because jsdom resolves --glass-tint-c to its 0 fallback. const opacity = 0.7; const opaqueL = dark ? 36.4 : 88; + /* Each cap, mirroring tokens.css / engine.css exactly. Written out per layer rather than shared, + because they genuinely differ: opaque SCALES then caps, chakra caps the raw chroma, crystal uses the + near-white -c-hi budget, and the gloss multiplies by --glass-gloss-tint before its own cap. */ + const opaqueC = Math.min(TINT_C * (dark ? 1.05 : 0.85), dark ? 0.12 : 0.055); + // --glass-solidify-c-max: the opaque cap × 0.65 in light, pinned back to the opaque cap in dark. + const solidifyC = Math.min(TINT_C * (dark ? 1.05 : 0.85), dark ? 0.12 : 0.055 * 0.65); + const tintCHi = Math.min(TINT_C, 0.017); // --glass-tint-c-hi + const glossC = Math.min(TINT_C * 4.25, dark ? 0.109 : 0.013); // --glass-gloss-tint × cap + const chakraC = Math.min(TINT_C, dark ? 0.046 : 0.055); // --glass-chakra-c-max /* The backing under SHEER glass is --glass-solidify-l, split from the opaque card floor: light lifts - it to 92, dark pins it back to --glass-opaque-l. Chroma is 0 throughout here because jsdom resolves - --glass-tint-c to its 0 fallback, so the derived cap never binds. */ + it to 92, dark pins it back to --glass-opaque-l. */ const solidify = { l: dark ? opaqueL : 92, - c: 0, + c: solidifyC, h, a: opacity, }; @@ -66,10 +80,17 @@ const surfaces = (dark: boolean): Record => { c: 0, h, }; - // Same gloss ink both sheer materials bake; chroma 0 here because jsdom resolves --glass-tint-c to 0. + // The tint wash, painted over solidify on every sheer material. + const wash = { + l: washL, + c: TINT_C * 2.5, + h, + a: TINT_A, + }; + // Same gloss ink both sheer materials bake — TINTED, not achromatic (--glass-gloss-ink). const gloss = { l: glossL, - c: 0, + c: glossC, h, a: GLOSS_TOP_A, }; @@ -78,8 +99,8 @@ const surfaces = (dark: boolean): Record => { dark, { h, - c: 0, - a: 0, + c: TINT_C, + a: TINT_A, }, 0.65, washL, @@ -88,32 +109,34 @@ const surfaces = (dark: boolean): Record => { ), "-opaque": { l: opaqueL, - c: 0, + c: opaqueC, h, }, // --glass-crystal-bg over the page, then solidify → wash → gloss. Composited in sRGB, the way the - // browser paints it; the crystal floor is --glass-crystal-l (96), NOT a flat L100. + // browser paints it; the crystal floor is --glass-crystal-l (96) at --glass-tint-c-hi × 0.6, NOT a + // flat L100 at the raw tint chroma. "-crystal": compositeSurface(page, [ { l: 96, - c: 0, + c: tintCHi * 0.6, h, a: crysA, }, solidify, + wash, gloss, ]), // The chakra BODY is translucent (--glass-chakra-a), so the page shows through it, and chakra bakes - // the same wash + gloss stack crystal does. The wash is absent here only because jsdom leaves - // --glass-tint-a at its 0 fallback. + // the same wash + gloss stack crystal does. "-chakra": compositeSurface(page, [ { l: dark ? 28 : 88, - c: 0, + c: chakraC, h, a: dark ? 0.58 : 0.62, }, solidify, + wash, gloss, ]), }; @@ -134,6 +157,11 @@ afterEach(() => { const mount = async (dark: boolean) => { if (dark) root().classList.add("dark"); + // Carry a real tint, as a preset does — without it jsdom resolves --glass-tint-c to 0 and every + // chroma cap in the models above is bypassed. + root().style.setProperty("--glass-tint-h", String(H)); + root().style.setProperty("--glass-tint-c", String(TINT_C)); + root().style.setProperty("--glass-tint-a", String(TINT_A)); render(); await waitFor(() => expect(read("--foreground-chakra")).not.toBe("")); }; @@ -196,24 +224,29 @@ describe.each([ * uncertainty margin — without a separate baseline aim it lands on the bare band target. Body aims * 80 + 4, muted 72 + 4. * - * LIGHT is now reach-limited on the body tier, and that is a real property of the floor rather than a + * LIGHT is reach-limited on the body tier, and that is a real property of the floor rather than a * slack expectation: dark text on a light surface gets more contrast the lighter the surface is, and * dropping the opaque floor from L90.9 to L88 (tokens.css — near-white is where the sRGB chroma * ceiling collapses, so the old floor could not hold its own tint) took the ceiling with it. At L88 - * even pure black reaches only 82.8 Lc, so the 84 aim is unreachable and the solve saturates. That is - * still above the bare 80 band target and well above the 75 body floor, which is what actually has to - * hold — so this asserts reaching the aim OR saturating above the bare target, not a fixed number. + * the floor reaches only 82.99 Lc even at pure black, so the 84 aim is unreachable and the solve + * saturates at the lightness extreme. + * + * Which is exactly where readableTonal now catches it: saturating means #000000, so the pick is + * clipped back to TONAL_MIN_L and delivers 82.08 rather than 82.99. That ~0.9 Lc is the whole cost of + * keeping the ink tinted, and it is what this number pins. (The floor's own chroma is NOT the cause — + * carrying it actually lifts the reach a touch, 82.83 → 82.99.) Still above the bare 80 band target + * and well clear of the 75 body floor, which is what has to hold. */ it("aims -opaque above the bare band target", async () => { await mount(dark); const s = surfaces(dark); const body = lcOn("--foreground-opaque", s["-opaque"]); - /* Dark has the headroom and must hit the aim exactly; light saturates at its ceiling. Asserting the - ceiling to 1dp keeps this honest — if the floor moves again the number moves and this test says so - rather than passing on a loose ">= 80". */ + /* Dark has the headroom and must hit the aim exactly; light saturates and is then pulled back by the + tonal clip. Asserting to 1dp keeps this honest — if the floor or the clip bound moves, the number + moves and this test says so rather than passing on a loose ">= 80". */ if (dark) expect(body).toBeCloseTo(84, 0); - else expect(body).toBeCloseTo(82.8, 1); + else expect(body).toBeCloseTo(82.1, 1); expect(body).toBeGreaterThan(80); // Muted aims 72 + 4 and stays reachable in both modes, so it is exact either way. From 3fc8ba511c2ad3d7eeb2c312608637f009887b52 Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:56:34 -0700 Subject: [PATCH 09/12] Done. One derivation, three consumers. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A **Foreground solve** section in `lib/oklch-utils.ts` — the right home because `auto-foreground` already declares `@sistine/oklch-utils` as a registry dependency, so it ships with no new wiring: ``` TONAL_MIN_L / TONAL_MAX_L / LC_MARGIN / LC_AIM_KNOWN showThrough(...alphas) the backdrop's surviving weight boostBand(band, boost, aim) target lifts; floors/ceilings never move foregroundRamp(opts) build + tonal clip, neutral-exempt pickTonalInBand(...) clipped pick + legibility guard readableTonal(...) adaptive twin + the same clip ``` `auto-foreground.tsx` shrank by ~190 lines and is now the *caller*, not the owner. `foreground-tester.tsx` and `check-contrast.mjs` call the same functions. The two files built **different-sized ramps**: the component picks from `count + 1` (the readable half), the tester from `2 × count + 1` — offering steps past the base that the surface can never make legible. That's now a documented `levels` default rather than a coincidence. It hadn't bitten on amethyst, but it was live. I checked the shared code is load-bearing rather than assuming it: disabling the clip inside `foregroundRamp` fails **4 DOM tests**. My first attempt at that check silently no-op'd — biome had reformatted the line my patch anchored on, so the assertion never fired and it "passed" against an unmodified file. Redone against the real text. Then drove Safari across five presets spanning both modes, both gamut arcs, and a neutral: ``` amethyst (dark) all tiers MATCH lapis (light) all tiers MATCH goldstone (light) all tiers MATCH turquoise (dark) all tiers MATCH selenite (light) all tiers MATCH ``` `lapis` first reported a mismatch — but that was my extraction: Bd and Fn resolve to the same index there, so the panel renders one **combined** marker (`Fn/Bd`) and my exact-text lookup missed it. Both tokens were identical. Fixed the probe, not the code. ``` ✓ 175 tests · lint · typecheck · contrast (77.1, unchanged) · gamut · APCA 0.000 ✓ theme (120 rules) · registry smoke (71 items) · patterns ``` The guard's number is bit-identical before and after, which is the signal the refactor changed no behaviour. Uncommitted: `lib/oklch-utils.ts`, both components, `check-contrast.mjs`, one test, and the two regenerated registry JSONs. --- components/auto-foreground.tsx | 193 ++++++---------------- components/foreground-tester.tsx | 78 +++++++-- lib/oklch-utils.ts | 175 ++++++++++++++++++++ public/r/auto-foreground.json | 2 +- public/r/oklch-utils.json | 2 +- scripts/check-contrast.mjs | 43 ++--- tests/auto-foreground-layer-caps.test.tsx | 6 +- 7 files changed, 320 insertions(+), 179 deletions(-) diff --git a/components/auto-foreground.tsx b/components/auto-foreground.tsx index dec14a9..65eda93 100644 --- a/components/auto-foreground.tsx +++ b/components/auto-foreground.tsx @@ -2,19 +2,22 @@ import * as React from "react"; import { - apcaContrast, - clampToGamut, + showThrough as backdropShowThrough, + boostBand, + type ContrastBand, compositeSurface, + foregroundRamp, formatOklch, glassSolidSurface, HARMONIC_OFFSETS, type HarmonicName, harmonicHue, - pickInBand, + LC_AIM_KNOWN, + LC_MARGIN, + pickTonalInBand, READABLE_USAGE, - readableForeground, + readableTonal, type ThemeForegroundOptions, - themeForeground, } from "@/lib/oklch-utils"; const FG_STORAGE_KEY = "sistine-fg"; @@ -64,19 +67,9 @@ const DEFAULT_FG: FgConfig = { softHue: null, strongHue: null, }; -/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is - * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000. - * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step - * kept is L20 (#211300), which is unambiguously tinted. */ -const TONAL_MIN_L = 18; -/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its - * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up, - * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a - * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */ -const TONAL_MAX_L = 97; -/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where - * text stops being readable rather than merely missing an aspirational target. */ -const LEGIBLE_FLOOR = READABLE_USAGE.body.floor; +/* The tonal clip bounds, the margins and the pick/solve themselves now live in lib/oklch-utils — see + the "Foreground solve" section there. They were duplicated into components/foreground-tester.tsx and + scripts/check-contrast.mjs, and every copy had drifted from this one in a way no test could see. */ const DEFAULT_RAMP: RampConfig = { l: 60, c: 0.15, @@ -237,7 +230,7 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness). - const LC_MARGIN = 12; + // (LC_MARGIN / LC_AIM_KNOWN are exported from lib/oklch-utils so every consumer aims the same.) // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it. @@ -247,7 +240,6 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick. - const LC_AIM_KNOWN = 4; const solidA = num("--glass-solid-a", 0.65); // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%, // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and @@ -318,8 +310,7 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor h: tintH, a: GLOSS_TOP_A, }; - const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1); - const normalLcBoost = LC_MARGIN * showThrough; + const normalLcBoost = LC_MARGIN * backdropShowThrough(solidA, tintA, glassOpacity); const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH); // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h. const fgHarmonyH = huelessAccent ? accentH : harmonyH; @@ -355,38 +346,19 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor c: huelessAccent ? accentC || cfgC : palette === "hue" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0, h: surfaceH, }; - // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral - // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. - const rawRamp = Array.from( - { - length: count + 1, - }, - (_, level) => - themeForeground({ - palette, - level, - count, - base, - dark, - }), - ); - /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either - extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any - requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff. - pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum - contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so - --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while - body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21. - The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance - barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs - 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is - clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken - deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor - was never actually held there — 11 of 21 presets miss it even at pure white. - NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the - genuine ends of a grey scale rather than colours that lost their hue. */ - const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L); - const ramp = base.c > 0 && tonal.length ? tonal : rawRamp; + /* Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral gray — + each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. Only the + READABLE half (count + 1 levels) is built: the far side of the base is display-only, and picking + from it would offer steps the surface can never make legible. + foregroundRamp also drops both achromatic ends on tinted themes — see lib/oklch-utils for why the + two thresholds are not symmetric. Before that clip, --foreground-strong was #000000 on 20 of 21 + presets in light and #ffffff on 18 of 21 in dark, and body text ALSO went black on 10 of 21. */ + const ramp = foregroundRamp({ + palette, + count, + base, + dark, + }); const iconHue = storedFg.iconHue; /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once per modeled surface: "" (the normal glass-SOLID floor), "-opaque", "-crystal", "-chakra". Each @@ -417,101 +389,34 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor ) => { // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed. - const boost = (band: { floor: number; target: number; ceiling: number }) => ({ - ...band, - target: Math.min(band.target + lcBoost + lcAim, band.ceiling), - }); - /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops - the steps that render black; if a band is so demanding that only those steps could satisfy its - FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body - bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its - lightness, chroma and step count are all user config), and a shallow enough ramp could put the - floor out of the clipped subset's reach. This keeps the preference from ever becoming a - readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */ - const pickTonal = (band: { floor: number; target: number; ceiling: number }) => { - const best = pickInBand(ramp, surface, band); - if (ramp === rawRamp) return best; - const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface)); - /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip - on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at - all and which in dark only pure white reaches, so a band.floor test hands the extreme straight - back. The body floor is the line that actually has to hold — below it text stops being - readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark - fine text at 80.1). Above that line, hue wins. */ - if (lc(best) >= LEGIBLE_FLOOR) return best; - const full = pickInBand(rawRamp, surface, band); - return lc(full) > lc(best) ? full : best; - }; - /* THE ADAPTIVE TWIN OF pickTonal. readableForeground aims at the band target and, when the target - sits beyond what the surface can give, returns the most contrast AVAILABLE — which is the - lightness extreme, exactly where the gamut annihilates the requested chroma and tinted text - renders #000 / #fff. That is the same failure the tonal ramp clip exists to prevent, on the - three surfaces the ramp cannot serve (they need a polarity the ramp does not span). - It fires in light mode by construction: an L88 opaque floor tops out at 81.1–84.3 Lc while the - opaque tiers aim at target + LC_AIM_KNOWN, so the aim is unreachable and every jewel's - --foreground-opaque solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc — the - same near-nothing the ramp clip trades, for the same reason (near the extreme, luminance barely - moves with lightness) — and returns a genuinely tinted ink. Dark is untouched: those solves land - at L92–93, comfortably inside the clip. - minChroma is NOT the lever here despite documenting this case: its search bisects toward the - BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the - dark-peaking arc, so the branch bails straight back to the extreme. Measured, minChroma 0.08 - still renders lapis/goldstone/rose black. It stays on for the accent path, which it was built - for and where the background IS able to hold the chroma. */ - const readableTonal = ( - band: { - floor: number; - target: number; - ceiling: number; - }, - hue: number, - chroma: number, - ) => { - const solved = readableForeground(surface, { - floor: band.floor, - target: band.target, - ceiling: band.ceiling, - hue, - chroma, - // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the - // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve - // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other - // theme passes 0 → behavior unchanged. - minChroma: huelessAccent ? 0.08 : 0, - }); - // NEUTRAL themes keep the full range: there black and white are the genuine ends of a grey - // scale, not colours that lost their hue — same exemption the ramp clip makes. - if (chroma <= 0) return solved; - const clippedL = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L); - if (clippedL === solved.l) return solved; - const clipped = clampToGamut({ - l: clippedL, - c: chroma, - h: hue, - }); - /* Guard LEGIBILITY, never the aspiration — the same line pickTonal holds. Every measured case - clears it with room (80.0–83.3 against a floor of 75), but a re-based ramp or a shallower - surface could not, and hue must never be bought with readability. */ - return Math.abs(apcaContrast(clipped, surface)) >= LEGIBLE_FLOOR ? clipped : solved; - }; + const boost = (band: ContrastBand) => boostBand(band, lcBoost, lcAim); + /* Pick from the TONAL ramp, never at the cost of the band's floor — see pickTonalInBand in + lib/oklch-utils. Today no band needs the fallback (light body bottoms out at 75.4 Lc on the + clipped ramp), but /colors lets a consumer re-base the ramp entirely, so the guard exists so + the hue preference can never become a readability regression. */ + /* The adaptive twin, readableTonal, also lives in lib/oklch-utils: an unreachable aim makes + readableForeground return the lightness EXTREME, where the gamut annihilates chroma, so every + jewel's --foreground-opaque solved to pure black in light. Same clip, same ~1 Lc cost. */ // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream). - const tier = (rawBand: { floor: number; target: number; ceiling: number }) => { + const tier = (rawBand: ContrastBand) => { const band = boost(rawBand); return formatOklch( adaptive - ? readableTonal( - band, + ? readableTonal(surface, band, { // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent. // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each // adaptive material composites a different stack, so each gets ink at its own angle. - huelessAccent ? accentH : surface.h, - huelessAccent ? accentC : tintC > 0 ? cfgC : 0, - ) - : pickTonal(band), + hue: huelessAccent ? accentH : surface.h, + chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0, + // Accent path ONLY — the one case where the background CAN hold the kept chroma, so the + // minChroma search does not bail to the extreme (see readableTonal). + minChroma: huelessAccent ? 0.08 : 0, + }) + : pickTonalInBand(ramp, surface, band), ); }; root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body)); @@ -534,7 +439,10 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor : formatOklch( // Clipped like every other solve: a PINNED hue is the one case where collapsing to the // achromatic extreme is most obviously wrong — the whole point of pinning is to see it. - readableTonal(boost(READABLE_USAGE[usage]), typeof choice === "string" ? harmonicHue(fgHarmonyH, choice) : choice, 0.15), + readableTonal(surface, boost(READABLE_USAGE[usage]), { + hue: typeof choice === "string" ? harmonicHue(fgHarmonyH, choice) : choice, + chroma: 0.15, + }), ); root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue("large", storedFg.softHue)); // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90, @@ -555,7 +463,12 @@ export function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoFor const iconH = typeof iconHue === "string" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === "number" ? iconHue : surface.h; root.style.setProperty( `--foreground-ui${suffix}`, - formatOklch(readableTonal(boost(READABLE_USAGE.ui), iconH, iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0)), + formatOklch( + readableTonal(surface, boost(READABLE_USAGE.ui), { + hue: iconH, + chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0, + }), + ), ); }; diff --git a/components/foreground-tester.tsx b/components/foreground-tester.tsx index 019ade9..6e0d92d 100644 --- a/components/foreground-tester.tsx +++ b/components/foreground-tester.tsx @@ -7,15 +7,20 @@ import { Card, CardContent } from "@/components/ui/card"; import { Slider } from "@/components/ui/slider"; import { apcaContrast, + boostBand, + foregroundRamp, formatOklch, glassSolidSurface, HARMONIC_NAMES, type HarmonicName, harmonicHue, + LC_MARGIN, type OklchColor, + pickTonalInBand, READABLE_USAGE, type ReadableUsage, readableForeground, + showThrough, themeForeground, } from "@/lib/oklch-utils"; import { cn } from "@/lib/utils"; @@ -147,6 +152,16 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: h: 255, }, count: 12, + /* The rest of the surface model. These were missing, and the omission is why this panel disagreed + with what AutoForeground actually emits: without them glassSolidSurface() falls back to the shared + wash lightness and — far more significantly — models NO solidify floor at all, the single heaviest + term in the composite at 0.7 alpha. Measured on amethyst night the panel's surface sat 3.3 L below + the real one, shifting every Lc in the strip by ~3 and widening the zero band by a whole step. */ + washL: 58, + washCMult: 2.5, + solidifyL: 36.4, + solidifyC: 0, + glassOpacity: 0.7, }); React.useEffect(() => { @@ -158,11 +173,24 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: return Number.isNaN(v) ? fb : v; }; const r = readRampConfig(); + const isDark = root.classList.contains("dark"); + const tintC = num("--glass-tint-c", 0); + const moonstone = root.dataset.glassTint === "moonstone"; + // Fallbacks mirror components/auto-foreground.tsx, which is the source of truth for this model. + const opaqueCMax = num("--glass-opaque-c-max", isDark ? 0.12 : 0.055); setEnv({ h: num("--glass-fg-h", num("--glass-tint-h", 255)), - c: num("--glass-tint-c", 0), + c: tintC, a: num("--glass-tint-a", 0), - dark: root.classList.contains("dark"), + dark: isDark, + washL: num("--glass-wash-l", moonstone && isDark ? 64 : isDark ? 58 : 72), + washCMult: num("--glass-wash-c-mult", moonstone && isDark ? 2 : 2.5), + solidifyL: num("--glass-solidify-l", isDark ? 36.4 : 92), + solidifyC: Math.min( + tintC * num("--glass-opaque-c-scale", isDark ? 1.05 : 0.85), + num("--glass-solidify-c-max", isDark ? opaqueCMax : opaqueCMax * 0.65), + ), + glassOpacity: Math.min(Math.max(num("--glass-opacity", 0.7), 0), 1), // Harmony anchor: matches CSS --harmony-h (content hue, or 0 for selenite/moonstone), so the chip hues here // land on the same angle as the --hue-* swatches. Falls back to the content hue when unset (jewels). harmonyH: num("--harmony-h", num("--glass-fg-h", num("--glass-tint-h", r.h))), @@ -192,6 +220,9 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: }; }, []); + /* The SAME surface AutoForeground bands against — full argument list. Passing only (dark, tint, solidA) + silently drops the per-preset wash lightness AND the solidify floor, which is what made this panel + disagree with the tokens it is meant to explain. */ const surface = glassSolidSurface( env.dark, { @@ -200,15 +231,28 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: a: env.a, }, solidA, + env.washL, + env.washCMult, + { + l: env.solidifyL, + c: env.solidifyC, + a: env.glassOpacity, + }, ); // neutral → achromatic base; an active tint adds (gamut-clamped) color. Text uses Tonal / Linear only. const baseChroma = env.c > 0 ? env.base.c : 0; const base = { l: env.base.l, + // The ramp is seeded from the COMPOSITED surface hue, not --glass-tint-h: the wash mixed over a + // near-neutral floor lands a few degrees off the declared angle, and AutoForeground follows the + // surface so text sits at the angle it is painted on. Using the token here showed a ramp the + // component never draws (amethyst night: 300 vs the surface's 301.4). c: baseChroma, - h: env.base.h, + h: surface.h, }; - // The full ramp: one extreme (black/white) → base (center) → the opposite extreme; picks come from the readable side. + /* The DISPLAY ramp: one extreme (black/white) → base (center) → the opposite extreme. Twice the + length of the pick pool on purpose — the far half is shown so the strip reads as a whole ramp, but + a foreground is only ever drawn from the readable side. */ const ramp: OklchColor[] = Array.from( { length: 2 * env.count + 1, @@ -224,16 +268,22 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: ); const lcOf = (c: OklchColor) => Math.abs(apcaContrast(c, surface)); - // Band-aware pick (honor floor ≤ Lc ≤ ceiling, aim for target), returning the ramp index. - const pickIdx = (band: { floor: number; target: number; ceiling: number }) => { - const scored = ramp.map((c, i) => ({ - i, - lc: lcOf(c), - })); - const inBand = scored.filter((s) => s.lc >= band.floor && s.lc <= band.ceiling); - const pool = inBand.length ? inBand : scored; - const err = (lc: number) => (inBand.length ? Math.abs(lc - band.target) : Math.min(Math.abs(lc - band.floor), Math.abs(lc - band.ceiling))); - return pool.reduce((best, s) => (err(s.lc) < err(best.lc) ? s : best), pool[0]).i; + /* The PICK, delegated to lib/oklch-utils so this panel cannot drift from what AutoForeground emits. + It did, on every axis: no solidify floor in the surface, no tonal clip (so `Fn` marked the ramp's + pure-WHITE end point, which the component never ships), no show-through margin, the raw tint hue + instead of the composited one, and a pick pool twice the size the component uses. */ + const solved = foregroundRamp({ + palette, + count: env.count, + base, + dark: env.dark, + }); + const lcBoost = LC_MARGIN * showThrough(solidA, env.a, env.glassOpacity); + /** Solve a tier and map the chosen colour back to its index in the DISPLAY ramp, for the markers. */ + const pickIdx = (rawBand: { floor: number; target: number; ceiling: number }) => { + const pick = pickTonalInBand(solved, surface, boostBand(rawBand, lcBoost)); + const i = solved.raw.indexOf(pick); + return i >= 0 ? i : 0; }; const tiers = TIERS.map((t) => { diff --git a/lib/oklch-utils.ts b/lib/oklch-utils.ts index f19d3af..f079ce3 100644 --- a/lib/oklch-utils.ts +++ b/lib/oklch-utils.ts @@ -1197,3 +1197,178 @@ export function readableForeground(bg: OklchColor | string, opts: ReadableForegr gamut, ); } + +// ── Foreground solve ───────────────────────────────────────────────────────── +// The model AutoForeground applies to every surface, lifted out of the component so the /colors tester +// and scripts/check-contrast.mjs run the SAME solve instead of each re-deriving it. Every one of them +// had drifted from the others, and none of the drifts was visible without measuring the emitted token: +// the tester modelled no solidify floor (3.3 L off), skipped the tonal clip (so it marked the ramp's +// pure-WHITE end point as the `fine` pick, a colour the component never emits), skipped the +// show-through margin, seeded the ramp from --glass-tint-h rather than the composited surface hue, and +// picked from the whole 2·count+1 ramp where the component only ever considers the readable half; the +// guard measured readableForeground where the page ships a discrete ramp pick, which hid four presets +// emitting #000000. Duplication was the root cause in all of them, so it is the thing being removed. + +/** A tier's contrast band: floor = legible minimum, target = aim, ceiling = anti-spike cap. */ +export interface ContrastBand { + floor: number; + target: number; + ceiling: number; +} + +/** Lightness below which an sRGB colour renders as black whatever chroma is asked for — the ramp's dark + * end is clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to + * #000. 18 sits just above L15 (#130900 at the warm hue, still reading black at text size). */ +export const TONAL_MIN_L = 18; +/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its + * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up, + * while near white a single 3-point step is worth 6–8 Lc. */ +export const TONAL_MAX_L = 97; +/** Uncertainty margin at a fully-sheer floor, decaying to 0 as the floor becomes exactly modelable. */ +export const LC_MARGIN = 12; +/** Baseline aim for a floor that IS modelled exactly, so certainty is not a reason to aim at the bare + * minimum. Only the opaque set earns it; every other surface earns its margin from uncertainty. */ +export const LC_AIM_KNOWN = 4; + +/** The backdrop's SURVIVING weight through a stack of alphas — every layer painted above attenuates it. + * This is what the uncertainty margin scales: the more backdrop shows, the less the model can be + * trusted. Pass each layer's alpha; order is irrelevant. */ +export function showThrough(...alphas: number[]): number { + return Math.min( + Math.max( + alphas.reduce((acc, a) => acc * (1 - Math.min(Math.max(a, 0), 1)), 1), + 0, + ), + 1, + ); +} + +/** Lift a band's TARGET by the uncertainty boost + the baseline aim. Floors and ceilings never move — + * the margin aims higher, it never legalizes a harsher pick than the band allowed. */ +export function boostBand(band: ContrastBand, lcBoost = 0, lcAim = 0): ContrastBand { + return { + ...band, + target: Math.min(band.target + lcBoost + lcAim, band.ceiling), + }; +} + +export interface ForegroundRampOptions { + palette: ThemeForegroundOptions["palette"]; + count: number; + base: OklchColor; + dark: boolean; + /** Levels to build. Defaults to the READABLE half (count + 1) — the only part a foreground is ever + * drawn from. The full 2·count+1 ramp exists for display; picking from it would offer steps on the + * far side of the base that the surface can never make legible. */ + levels?: number; +} + +/** The ramp a foreground is picked from: the full build, plus the tonally-clipped subset. */ +export interface ForegroundRamp { + raw: OklchColor[]; + tonal: OklchColor[]; +} + +/** + * Build the foreground ramp and its tonal clip. + * + * Both achromatic ends are dropped on TINTED themes: sRGB has almost no gamut volume there, so those + * steps are pure black / pure white wearing a theme colour's name (at L0 any requested chroma renders + * #000000; at L100, #ffffff). pickInBand reaches them whenever a band's target sits above every step — + * it then picks for maximum contrast, which IS the extreme. NEUTRAL themes (base chroma 0) keep the + * full ramp, since there black and white are the genuine ends of a grey scale rather than colours that + * lost their hue. + */ +export function foregroundRamp(o: ForegroundRampOptions): ForegroundRamp { + const levels = o.levels ?? o.count + 1; + const raw = Array.from( + { + length: levels, + }, + (_, level) => + themeForeground({ + palette: o.palette, + level, + count: o.count, + base: o.base, + dark: o.dark, + }), + ); + if (o.base.c <= 0) + return { + raw, + tonal: raw, + }; + const tonal = raw.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L); + return { + raw, + tonal: tonal.length ? tonal : raw, + }; +} + +/** + * Pick from the TONAL ramp, but never at the cost of the band's floor. + * + * The clip drops the steps that render black or white; if a band is so demanding that only those could + * satisfy its FLOOR, legibility outranks hue and the full ramp comes back. Guard the FLOOR, not each + * band's aspiration: the `small` band asks Lc 90, which no light surface reaches at all, so testing + * against band.floor there would hand the extreme straight back on every light preset. + */ +export function pickTonalInBand(ramp: ForegroundRamp, surface: OklchColor | string, band: ContrastBand): OklchColor { + const best = pickInBand(ramp.tonal, surface, band); + if (ramp.tonal === ramp.raw) return best; + const lc = (c: OklchColor) => Math.abs(apcaContrast(c, surface)); + if (lc(best) >= READABLE_USAGE.body.floor) return best; + const full = pickInBand(ramp.raw, surface, band); + return lc(full) > lc(best) ? full : best; +} + +/** + * The ADAPTIVE twin of {@link pickTonalInBand}, for surfaces the ramp cannot serve (they may need a + * polarity it does not span — a LIGHT opaque card on a dark page needs DARK text). + * + * readableForeground aims at the band target and, when the target sits beyond what the surface can + * give, returns the most contrast AVAILABLE — which is the lightness extreme, exactly where the gamut + * annihilates the requested chroma. In light mode that is the default rather than an edge case: an L88 + * opaque floor tops out around 81–84 Lc while the opaque tiers aim at target + LC_AIM_KNOWN, so every + * jewel's `--foreground-opaque` solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc and + * returns a genuinely tinted ink. + * + * `minChroma` is NOT a substitute despite documenting this case: its search bisects toward the + * BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the + * dark-peaking arc, so it bails back to the extreme. It stays on for the accent path it was built for. + */ +export function readableTonal( + surface: OklchColor, + band: ContrastBand, + opts: { + hue: number; + chroma: number; + minChroma?: number; + gamut?: "srgb" | "p3"; + }, +): OklchColor { + const solved = readableForeground(surface, { + floor: band.floor, + target: band.target, + ceiling: band.ceiling, + hue: opts.hue, + chroma: opts.chroma, + minChroma: opts.minChroma ?? 0, + gamut: opts.gamut, + }); + // Neutral keeps the full range — black and white are the genuine ends of a grey scale there. + if (opts.chroma <= 0) return solved; + const l = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L); + if (l === solved.l) return solved; + const clipped = clampToGamut( + { + l, + c: opts.chroma, + h: opts.hue, + }, + opts.gamut, + ); + // Guard legibility, never the aspiration — the same line pickTonalInBand holds. + return Math.abs(apcaContrast(clipped, surface)) >= READABLE_USAGE.body.floor ? clipped : solved; +} diff --git a/public/r/auto-foreground.json b/public/r/auto-foreground.json index 59eb204..9300419 100644 --- a/public/r/auto-foreground.json +++ b/public/r/auto-foreground.json @@ -11,7 +11,7 @@ "files": [ { "path": "components/auto-foreground.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n apcaContrast,\n clampToGamut,\n compositeSurface,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n pickInBand,\n READABLE_USAGE,\n readableForeground,\n type ThemeForegroundOptions,\n themeForeground,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/** Lightness below which an sRGB colour renders as black no matter its chroma — the ramp's dark end is\n * clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to #000.\n * 18 sits just above L15 (#130900 at the warm hue, still reading black at text size); the darkest step\n * kept is L20 (#211300), which is unambiguously tinted. */\nconst TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. 97 clips the pure-white end point (and, on a\n * finer ramp, the step just below it) while keeping L96.7 — #eff4ff at the blue hue, faint but tinted. */\nconst TONAL_MAX_L = 97;\n/** The contrast line the tonal clip may never trade away: the body band's floor, i.e. the point where\n * text stops being readable rather than merely missing an aspirational target. */\nconst LEGIBLE_FLOOR = READABLE_USAGE.body.floor;\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n const LC_MARGIN = 12;\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const LC_AIM_KNOWN = 4;\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const showThrough = Math.min(Math.max((1 - solidA) * (1 - tintA) * (1 - glassOpacity), 0), 1);\n const normalLcBoost = LC_MARGIN * showThrough;\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n // Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral\n // gray — each picked to hit its ARC-Bronze contrast target on the surface text actually sits on.\n const rawRamp = Array.from(\n {\n length: count + 1,\n },\n (_, level) =>\n themeForeground({\n palette,\n level,\n count,\n base,\n dark,\n }),\n );\n /* Drop BOTH achromatic ends of the ramp on tinted themes. sRGB has almost no gamut volume at either\n extreme, so the steps there are pure black / pure white wearing a theme colour's name: at L0 any\n requested chroma renders #000000 (L5 → #030000, L10 → #070200), and at L100 it renders #ffffff.\n pickInBand reaches them whenever a band's target sits above every step — it then picks for maximum\n contrast, which IS the extreme. The `small` band (floor Lc 90) does exactly that in both modes, so\n --foreground-strong was #000000 on 20 of 21 presets in light and #ffffff on 18 of 21 in dark, while\n body text one band down stayed properly tonal. Light was worse: body ALSO went black on 10 of 21.\n The two ends are not symmetric, which is why the thresholds are not either. Near black, luminance\n barely moves with lightness, so the whole L0–L18 span is visually one colour and giving it up costs\n 1.3–1.9 Lc — nothing. Near white the same lightness step is worth 6–8 Lc, so only the very top is\n clipped, and fine text in dark lands 80–91 Lc instead of 86–97. That is a real reduction, taken\n deliberately: it matches what light mode already ships for the same tier (76–88), and the 90 floor\n was never actually held there — 11 of 21 presets miss it even at pure white.\n NEUTRAL themes (selenite: base chroma 0) keep the full ramp, since there black and white are the\n genuine ends of a grey scale rather than colours that lost their hue. */\n const tonal = rawRamp.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n const ramp = base.c > 0 && tonal.length ? tonal : rawRamp;\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: { floor: number; target: number; ceiling: number }) => ({\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n });\n /* Pick from the TONAL ramp, but never at the cost of the band's floor. The clipped ramp above drops\n the steps that render black; if a band is so demanding that only those steps could satisfy its\n FLOOR, legibility outranks hue and the full ramp comes back. Today no band needs it — light body\n bottoms out at 75.4 Lc on the clipped ramp — but /colors lets a consumer re-base the ramp (its\n lightness, chroma and step count are all user config), and a shallow enough ramp could put the\n floor out of the clipped subset's reach. This keeps the preference from ever becoming a\n readability regression, rather than relying on TONAL_MIN_L being right for every ramp. */\n const pickTonal = (band: { floor: number; target: number; ceiling: number }) => {\n const best = pickInBand(ramp, surface, band);\n if (ramp === rawRamp) return best;\n const lc = (c: { l: number; c: number; h: number }) => Math.abs(apcaContrast(c, surface));\n /* Guard LEGIBILITY, not each band's aspiration. Checking against band.floor would undo the clip\n on the one tier it exists for: the `small` band asks Lc 90, which no light surface reaches at\n all and which in dark only pure white reaches, so a band.floor test hands the extreme straight\n back. The body floor is the line that actually has to hold — below it text stops being\n readable — and every clipped pick clears it comfortably (light body bottoms out at 75.4, dark\n fine text at 80.1). Above that line, hue wins. */\n if (lc(best) >= LEGIBLE_FLOOR) return best;\n const full = pickInBand(rawRamp, surface, band);\n return lc(full) > lc(best) ? full : best;\n };\n /* THE ADAPTIVE TWIN OF pickTonal. readableForeground aims at the band target and, when the target\n sits beyond what the surface can give, returns the most contrast AVAILABLE — which is the\n lightness extreme, exactly where the gamut annihilates the requested chroma and tinted text\n renders #000 / #fff. That is the same failure the tonal ramp clip exists to prevent, on the\n three surfaces the ramp cannot serve (they need a polarity the ramp does not span).\n It fires in light mode by construction: an L88 opaque floor tops out at 81.1–84.3 Lc while the\n opaque tiers aim at target + LC_AIM_KNOWN, so the aim is unreachable and every jewel's\n --foreground-opaque solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc — the\n same near-nothing the ramp clip trades, for the same reason (near the extreme, luminance barely\n moves with lightness) — and returns a genuinely tinted ink. Dark is untouched: those solves land\n at L92–93, comfortably inside the clip.\n minChroma is NOT the lever here despite documenting this case: its search bisects toward the\n BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the\n dark-peaking arc, so the branch bails straight back to the extreme. Measured, minChroma 0.08\n still renders lapis/goldstone/rose black. It stays on for the accent path, which it was built\n for and where the background IS able to hold the chroma. */\n const readableTonal = (\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n hue: number,\n chroma: number,\n ) => {\n const solved = readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n hue,\n chroma,\n // Accent path ONLY: keep the accent VISIBLE on floors whose max contrast sits below the\n // band (moonstone-dark cream L80 tops out at Lc ≈ 68 < body floor 75) — without this the solve\n // pins to L≈0, the gamut collapses chroma, and accent text renders BLACK. Every other\n // theme passes 0 → behavior unchanged.\n minChroma: huelessAccent ? 0.08 : 0,\n });\n // NEUTRAL themes keep the full range: there black and white are the genuine ends of a grey\n // scale, not colours that lost their hue — same exemption the ramp clip makes.\n if (chroma <= 0) return solved;\n const clippedL = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L);\n if (clippedL === solved.l) return solved;\n const clipped = clampToGamut({\n l: clippedL,\n c: chroma,\n h: hue,\n });\n /* Guard LEGIBILITY, never the aspiration — the same line pickTonal holds. Every measured case\n clears it with room (80.0–83.3 against a floor of 75), but a re-based ramp or a shallower\n surface could not, and hue must never be bought with readability. */\n return Math.abs(apcaContrast(clipped, surface)) >= LEGIBLE_FLOOR ? clipped : solved;\n };\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: { floor: number; target: number; ceiling: number }) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(\n band,\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n huelessAccent ? accentH : surface.h,\n huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n )\n : pickTonal(band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(boost(READABLE_USAGE[usage]), typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice, 0.15),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(readableTonal(boost(READABLE_USAGE.ui), iconH, iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0)),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same\n // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason\n // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the\n backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the\n gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an\n UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known\n than the model claimed. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n showThrough as backdropShowThrough,\n boostBand,\n type ContrastBand,\n compositeSurface,\n foregroundRamp,\n formatOklch,\n glassSolidSurface,\n HARMONIC_OFFSETS,\n type HarmonicName,\n harmonicHue,\n LC_AIM_KNOWN,\n LC_MARGIN,\n pickTonalInBand,\n READABLE_USAGE,\n readableTonal,\n type ThemeForegroundOptions,\n} from \"@/lib/oklch-utils\";\n\nconst FG_STORAGE_KEY = \"sistine-fg\";\nconst RAMP_KEY = \"sistine-ramp\";\nconst FG_EVENT = \"sistine-fg\";\n\n// Dev instrumentation: set localStorage[\"sistine-fg-debug\"] = \"1\" (then reload) to log each update()'s\n// wall-time and how far it nudges --foreground off the CSS baseline (the \"marginal bump\"). Off by default.\nconst fgDebug = () => {\n try {\n return typeof localStorage !== \"undefined\" && localStorage.getItem(\"sistine-fg-debug\") === \"1\";\n } catch {\n return false;\n }\n};\n\nexport type FgPalette = ThemeForegroundOptions[\"palette\"];\nexport interface FgConfig {\n palette: FgPalette;\n /** Icon foreground hue for `--foreground-ui`: a number (0–360) pins a hue; a harmonic name\n * (\"complement\" | \"triad-1\" | \"split-2\" | … — a color-wheel relationship rotated off --harmony-h,\n * tracked live); null → icons follow the theme/text color. Always contrast-solved (APCA/ARC). */\n iconHue: number | HarmonicName | null;\n /** Heading/large-text hue for `--foreground-soft` — same semantics as iconHue (number pins, a harmonic\n * name rotates off the theme live, null → follow the chosen palette ramp). */\n softHue: number | HarmonicName | null;\n /** Fine/small-text hue for `--foreground-strong` — same semantics (null → follow the palette ramp). */\n strongHue: number | HarmonicName | null;\n}\n/** The /colors ramp generator's base color + step count, shared with the foreground. */\nexport interface RampConfig {\n l: number;\n c: number;\n h: number;\n count: number;\n}\n\nconst FG_PALETTES: FgPalette[] = [\n \"tonal\",\n \"lightness\",\n \"hue\",\n \"chroma\",\n];\nconst DEFAULT_FG: FgConfig = {\n palette: \"lightness\", // linear ramp — holds the theme's chroma, so high-contrast text reads as a soft tinted white, not gray\n iconHue: null,\n softHue: null,\n strongHue: null,\n};\n/* The tonal clip bounds, the margins and the pick/solve themselves now live in lib/oklch-utils — see\n the \"Foreground solve\" section there. They were duplicated into components/foreground-tester.tsx and\n scripts/check-contrast.mjs, and every copy had drifted from this one in a way no test could see. */\nconst DEFAULT_RAMP: RampConfig = {\n l: 60,\n c: 0.15,\n h: 255,\n count: 12, // finest ramp (12 steps/side) — the most cohesive foreground set in practice\n};\n\n/** Normalize a stored hue choice: a harmonic relationship name, a pinned number, else null. */\nconst hueChoice = (v: unknown): number | HarmonicName | null =>\n typeof v === \"string\" && v in HARMONIC_OFFSETS ? (v as HarmonicName) : typeof v === \"number\" ? v : null;\n\n/** Read the persisted foreground palette; falls back to the default (Linear). */\nexport function readFgConfig(): FgConfig {\n try {\n const raw = localStorage.getItem(FG_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Partial;\n if (FG_PALETTES.includes(parsed.palette as FgPalette)) {\n return {\n palette: parsed.palette as FgPalette,\n iconHue: hueChoice(parsed.iconHue),\n softHue: hueChoice(parsed.softHue),\n strongHue: hueChoice(parsed.strongHue),\n };\n }\n }\n } catch {\n // ignore parse / storage failures\n }\n return DEFAULT_FG;\n}\n\n/** Persist the foreground config + notify AutoForeground to re-apply it site-wide. */\nexport function writeFgConfig(config: Partial): void {\n try {\n localStorage.setItem(\n FG_STORAGE_KEY,\n JSON.stringify({\n ...readFgConfig(),\n ...config,\n }),\n );\n } catch {\n // ignore storage failures\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\n/** Read the ramp generator's base color + count (shared from the /colors ramp card). */\nexport function readRampConfig(): RampConfig {\n try {\n const raw = localStorage.getItem(RAMP_KEY);\n if (raw) {\n const p = JSON.parse(raw) as Partial;\n if (\n [\n p.l,\n p.c,\n p.h,\n p.count,\n ].every((n) => typeof n === \"number\")\n ) {\n return {\n l: p.l as number,\n c: p.c as number,\n h: p.h as number,\n count: p.count as number,\n };\n }\n }\n } catch {\n // ignore\n }\n return DEFAULT_RAMP;\n}\n\n/** Persist the ramp base color + count + notify AutoForeground (called by the ramp generator). */\nexport function writeRampConfig(config: RampConfig): void {\n try {\n localStorage.setItem(RAMP_KEY, JSON.stringify(config));\n } catch {\n // ignore\n }\n window.dispatchEvent(new Event(FG_EVENT));\n}\n\nexport interface AutoForegroundProps {\n /** Foreground ramp palette. Overrides the persisted config when set. */\n palette?: FgPalette;\n /** Ramp base color + step count. Overrides the persisted ramp when set. */\n ramp?: RampConfig;\n}\n\n/**\n * Sets the foreground tokens on by drawing COLORS from the chosen OKLCH ramp (palette + base\n * color + step count): `--foreground`, `--muted-foreground`, and the ARC-Bronze size tiers\n * `--foreground-soft` (large) / `--foreground-strong` (fine), plus the icon foreground `--foreground-ui`\n * (ui band, optional hue). Each is picked from that ramp to hit its contrast target on the glass-SOLID\n * surface text sits on — so foregrounds are real theme colors, not neutral gray, and track light/dark +\n * tint automatically. globals.css carries static fallbacks (no flash); the tiers are exposed as the\n * `text-foreground-soft` / `-strong` / `-ui` utilities.\n *\n * Configure declaratively — `` — or, with no\n * props, it reads a persisted config (`writeRampConfig`, e.g. the /colors generator) and re-applies on the\n * `sistine-fg` event. Mount it once at the app root. The foreground level is contrast-target-driven\n * (the ARC-Bronze band per tier), not a manual ramp index.\n */\nexport function AutoForeground({ palette: paletteProp, ramp: rampProp }: AutoForegroundProps = {}) {\n const rl = rampProp?.l;\n const rc = rampProp?.c;\n const rh = rampProp?.h;\n const rcount = rampProp?.count;\n\n React.useEffect(() => {\n const root = document.documentElement;\n\n const update = (inputs?: Record) => {\n const dbg = fgDebug();\n const t0 = dbg ? performance.now() : 0;\n const dark = root.classList.contains(\"dark\");\n const storedFg = readFgConfig();\n const storedRamp = readRampConfig();\n const palette = paletteProp ?? storedFg.palette;\n const count = rcount ?? storedRamp.count;\n // A snapshot from the switcher (the drag hot path) lets us skip getComputedStyle — the read-after-write\n // forced reflow measured at 7–22ms. Fall back to the DOM for mount / mode toggle / frescoes (no snapshot).\n const cs = inputs ? null : getComputedStyle(root);\n const fgBefore = dbg ? (cs ? cs.getPropertyValue(\"--foreground\").trim() : root.style.getPropertyValue(\"--foreground\").trim()) : \"\";\n const num = (name: string, fb: number) => {\n const v = inputs ? inputs[name] : cs ? Number.parseFloat(cs.getPropertyValue(name)) : Number.NaN;\n return v == null || Number.isNaN(v) ? fb : v;\n };\n // Foregrounds FOLLOW THE CHOSEN FOREGROUND HUE: the ramp's hue is --glass-fg-h (which defaults to\n // the glass tint --glass-tint-h, but frescoes set it apart so text anchors off their surface).\n // Lightness + chroma (vividness) and step count come from the /colors ramp config. Picks are\n // measured on the glass-SOLID surface body text sits on — a known surface, so a real Lc.\n const tintH = num(\"--glass-fg-h\", num(\"--glass-tint-h\", rh ?? storedRamp.h));\n const tintA = num(\"--glass-tint-a\", 0);\n // Colorfulness gate — CHROMA is the single master now that Wash is retired: tint-c > 0 → colored\n // foregrounds (at the ramp's vividness) + hue-tracking harmonics; chroma 0 → achromatic. (tintA still\n // feeds the glass-solid SURFACE color below, but no longer decides \"is this theme colored?\".)\n const tintC = num(\"--glass-tint-c\", 0);\n // Harmony anchor — the wheel origin the icon/foreground harmonics rotate from. Mirrors the CSS\n // --harmony-h (content hue, or 0 for the hue-less selenite/moonstone themes set inline by the tint switcher);\n // falls back to the content hue when unset (jewels), so JS harmonics land on the SAME angle as the\n // --hue-* swatches. harmonicHue(fgHarmonyH, name) below matches calc(var(--harmony-h) + N) exactly.\n const harmonyH = num(\"--harmony-h\", tintH);\n const cfgC = rc ?? storedRamp.c;\n // User accent: on the hue-LESS themes only (selenite + moonstone anchor --harmony-h at 0), a chosen accent\n // colors ALL text tiers — its hue + vividness drive the ramp base, so foregrounds tint toward the accent\n // instead of gray (selenite) / warm-moonstone. Frescoes (--harmony-h != 0) are untouched. Band-picking below\n // still hits each tier's ARC-Bronze APCA target, so accent-tinted text stays legible.\n const accentH = num(\"--accent-h\", Number.NaN);\n const accentC = num(\"--accent-c\", 0.15);\n // Uncertainty-aware contrast margin. The normal tiers are banded against the veiled floor MODEL,\n // whose only unknown is the backdrop showing through — and the backdrop's weight in that mix is\n // exactly (1 − solidA)·(1 − tintA) (see glassSolidSurface). The more the backdrop shows, the less\n // the model can be trusted, so each band's TARGET gets a safety margin of up to +LC_MARGIN\n // (≈ one ARC band step) at fully-sheer, decaying to +0 at a fully-known floor (solidA 1 — e.g. the\n // opaque page style sets --glass-solid-a: 1). Ceilings still cap the pick (anti-harshness).\n // (LC_MARGIN / LC_AIM_KNOWN are exported from lib/oklch-utils so every consumer aims the same.)\n // Parity aim for a FULLY-KNOWN floor. LC_MARGIN above is an UNCERTAINTY margin — it decays to 0 as the\n // floor becomes exactly modelable, which is right on its own terms but leaves the one surface we model\n // exactly (opaque) aiming at the BARE band target while every other surface aims 3.6–5.7 Lc above it.\n // Measured at the shipped defaults that made opaque the lowest-contrast surface in the system for every\n // tint in BOTH modes (body 80.0 vs 82.0–87.3, muted 72.0 vs 73.0–79.3) — the \"soft / out-of-focus\"\n // opaque card. Certainty about the floor is no reason to aim at the minimum, so a known floor gets this\n // baseline aim instead. Sized to the MIDDLE of the other surfaces' effective margins so opaque lands\n // LEVEL with them; the full LC_MARGIN here would pin body+muted to the band ceiling and just invert the\n // asymmetry (opaque becomes the harshest surface). Ceilings still cap the pick.\n const solidA = num(\"--glass-solid-a\", 0.65);\n // Wash knobs — moonstone is the ONE preset that overrides them at night (--glass-wash-l: 72%,\n // --glass-wash-c-mult: 2 — its pale-cream character), which the hardcoded model missed and\n // every moonstone-night surface banded ~3 L too dark (bright page, faint text). num() reads the\n // truth on the computed path; snapshots can't carry these, so the FALLBACK is moonstone-aware via\n // the data-glass-tint attribute (cheap, race-free on both paths). Every other theme resolves\n // to the standard mode values either way — this is a moonstone-only correction by construction.\n // check-theme [moonstone-sync] keeps these mirrored constants equal to presets.css.\n const moonstone = root.dataset.glassTint === \"moonstone\";\n const washL = num(\"--glass-wash-l\", moonstone && dark ? 64 : dark ? 58 : 72);\n const washCMult = num(\"--glass-wash-c-mult\", moonstone && dark ? 2 : 2.5);\n // THE SOLIDIFY FLOOR — `glass` paints --glass-solidify (the --glass-opacity dial, default 0.7) as the\n // bottom background-image layer of EVERY sheer material, so it is 70% of what text actually sits on.\n // The models below used to skip it entirely and band against the sheer floor alone. On most themes the\n // opaque floor sits the same side of mid-grey as the page, so that cost a few Lc (utilities.css measured\n // body 91.5 → 86.5). Moonstone NIGHT is the case that breaks it: a cream L84.9 opaque floor under an L20\n // page, i.e. the two OPPOSE, so the crystal model landed 27.5 L too dark, called for near-white text, and\n // reported Lc 87.1 for a surface that actually delivers 52.2 — below the body floor of 75, silently.\n // Reading it here fixes every sheer surface at once, and the fallbacks mirror tokens.css.\n const glassOpacity = Math.min(Math.max(num(\"--glass-opacity\", 0.7), 0), 1);\n /* The CAP is not cosmetic: engine.css paints this floor as\n `min(--glass-tint-c * --glass-opaque-c-scale, --glass-opaque-c-max)`, because a jewel's tint chroma\n scaled up would leave the sRGB gamut at the opaque floor's lightness and WebKit clips out-of-gamut\n oklch() per channel rather than reducing chroma — which trades lightness away and drifts hue. The\n cap is what each preset's own ceiling is FOR. Modelling the floor uncapped bands text against a\n surface more colourful (and so slightly darker) than the one actually painted; scripts/\n check-contrast.mjs already clamps here, so an uncapped model here also silently disagrees with the\n guard that signs the presets off. Keep the three in step. */\n const opaqueCMax = num(\"--glass-opaque-c-max\", dark ? 0.12 : 0.055);\n /* --glass-solidify-*, NOT --glass-opaque-*: the backing under sheer glass is its own surface (see\n tokens.css). It is the one high-weight term in the composite that is not part of a preset's\n declared identity, which is why LIGHT lifts it to 92 to buy body-text contrast without touching\n a single tint token. Dark pins both back to the opaque floor, so this reads identically there.\n Fallbacks mirror tokens.css: the derived cap is --glass-opaque-c-max × 0.65 in light. */\n const solidifyFloor = {\n l: num(\"--glass-solidify-l\", dark ? 36.4 : 92),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), num(\"--glass-solidify-c-max\", dark ? opaqueCMax : opaqueCMax * 0.65)),\n a: glassOpacity,\n };\n /** The solidify floor as a compositable layer — the layer order `glass` paints. */\n const solidifyLayer = {\n l: solidifyFloor.l,\n c: solidifyFloor.c,\n h: tintH,\n a: solidifyFloor.a,\n };\n /* --glass-tint-c-hi (engine.css): the HIGHLIGHT chroma budget, min(--glass-tint-c, 0.017). The\n sheet stops and the crystal floor are all scaled off this, not off raw --glass-tint-c — at\n near-white lightnesses the gamut ceiling collapses, so the cap is what keeps those layers\n rendering what they ask for. check-theme [gamut] holds the 0.017 in step with engine.css. */\n const TINT_C_HI_MAX = 0.017;\n const tintCHi = Math.min(tintC, TINT_C_HI_MAX);\n /* The gloss triple (top / streak / glow) — crystal AND chakra bake the same --glass-gloss-ink, so\n it is derived once here. Only the TOP highlight is modeled: it peaks at 0.4α and fades out by\n 30% height, so ≈0.2 is its mean across the title zone (modeling the 0.4 peak would make the band\n unsatisfiable on mid-gray). The streak (0.15α on a 135° diagonal) and glow (0.2α radial centred\n at 50% 120%, i.e. BELOW the card) are left out: the glow is past its 70% fade before it reaches\n the title zone, and the streak's mean there is a geometry estimate rather than a measurement.\n Both would only ADD light, so omitting them is the conservative direction. */\n const GLOSS_TOP_A = 0.2;\n const glossLayer = {\n /* Mode-aware fallback: --glass-gloss-l is a twin (97 light / 66 dark), so a single 66 here would\n model the light crystal surface ~6 L darker than it renders and band text too weak. */\n l: num(\"--glass-gloss-l\", dark ? 66 : 97),\n // --glass-gloss-ink is TINTED — min(--glass-tint-c × --glass-gloss-tint, --glass-gloss-c-max).\n // Modeling it achromatic dropped the gloss's colour from every crystal/chakra band.\n c: Math.min(tintC * num(\"--glass-gloss-tint\", 4.25), num(\"--glass-gloss-c-max\", dark ? 0.109 : 0.013)),\n h: tintH,\n a: GLOSS_TOP_A,\n };\n const normalLcBoost = LC_MARGIN * backdropShowThrough(solidA, tintA, glassOpacity);\n const huelessAccent = harmonyH === 0 && !Number.isNaN(accentH);\n // The wheel origin harmonics rotate from — the accent on hue-less+accent, else the CSS --harmony-h.\n const fgHarmonyH = huelessAccent ? accentH : harmonyH;\n /* The veiled floor body text sits on (page + translucent/veiled cards). Derived HERE, above the\n ramp, because the ramp's hue comes from it — see below. */\n const normalSurface = glassSolidSurface(\n dark,\n {\n h: tintH,\n c: tintC,\n a: tintA,\n },\n solidA,\n washL,\n washCMult,\n solidifyFloor,\n );\n /* TEXT FOLLOWS THE SURFACE'S HUE, NOT THE TINT TOKEN'S. --glass-tint-h is what the WASH declares;\n what a reader sees is the wash composited over the solid/solidify floor, and that mix does not\n travel a radial path — the floor is near-neutral, so it has almost no hue to interpolate toward\n and the result lands a few degrees off the declared angle (measured light: rose 8 → 2.5, goldstone\n 22 → 17.1, lapis 268 → 271.7). Seeding the ramp from tintH therefore painted text at an angle the\n surface underneath it never actually occupies. Opaque is the one surface unaffected: it is a solid\n painted colour with nothing composited over it, so its hue IS tintH and surface.h returns exactly\n that. The residual spread BETWEEN materials is ≤5.5°, which at the ink's chroma (~0.08) sits well\n under a just-noticeable difference — far smaller than the ink-vs-surface mismatch it removes. */\n const surfaceH = huelessAccent ? accentH : normalSurface.h;\n // A neutral tint → ACHROMATIC foregrounds (black/white/gray by lightness). EXCEPTION: the Hue palette\n // stays a full-spectrum color wheel even when neutral. A hue-less accent overrides both — it hue +\n // vividnesses the whole ramp so every text tier tints toward the chosen accent.\n const base = {\n l: rl ?? storedRamp.l,\n c: huelessAccent ? accentC || cfgC : palette === \"hue\" ? cfgC || 0.15 : tintC > 0 ? cfgC : 0,\n h: surfaceH,\n };\n /* Draw every foreground from the chosen tonal/lightness ramp — real theme COLORS, not neutral gray —\n each picked to hit its ARC-Bronze contrast target on the surface text actually sits on. Only the\n READABLE half (count + 1 levels) is built: the far side of the base is display-only, and picking\n from it would offer steps the surface can never make legible.\n foregroundRamp also drops both achromatic ends on tinted themes — see lib/oklch-utils for why the\n two thresholds are not symmetric. Before that clip, --foreground-strong was #000000 on 20 of 21\n presets in light and #ffffff on 18 of 21 in dark, and body text ALSO went black on 10 of 21. */\n const ramp = foregroundRamp({\n palette,\n count,\n base,\n dark,\n });\n const iconHue = storedFg.iconHue;\n /* Set the full foreground tier set against a given SURFACE, under a var suffix. Run FOUR times, once\n per modeled surface: \"\" (the normal glass-SOLID floor), \"-opaque\", \"-crystal\", \"-chakra\". Each\n material's [data-material] block remaps --foreground* → its own suffixed set, so a LIGHT opaque\n floor (dark-mode moonstone cream) gets DARK card text while the dark page keeps light text — one\n global foreground cannot do both.\n FIVE materials share those four sets: `glass` and `frosted` both fall through to \"\". They are not\n quite the same surface — frosted paints --glass-frosted-bg, the same sheet lifted by\n --glass-frost-boost (0.1 light / 0.06 dark) — and the \"\" model deliberately stops at the wash, so\n NEITHER sheet is in it. Both are therefore banded against a floor darker than what renders, which\n is the safe direction (measured light: text lands 2.2–2.6 Lc over-contrasted on glass, 4.2–4.6 on\n frosted, and no tier breaches its band ceiling). Frosted is the loosest fit in the system; giving\n it a fifth set would tighten it. */\n const applyTiers = (\n surface: {\n l: number;\n c: number;\n h: number;\n },\n suffix: string,\n adaptive: boolean,\n lcBoost = 0,\n // Baseline aim, kept SEPARATE from lcBoost so the two stay honest: lcBoost answers \"how much don't\n // we know about this floor\", lcAim answers \"how far above the bare minimum should we aim on a floor\n // we DO know\". Only the opaque set passes it (see LC_AIM_KNOWN); every other surface earns its\n // margin from uncertainty and leaves this 0.\n lcAim = 0,\n ) => {\n // Lift the band's TARGET toward its ceiling by the uncertainty boost + the baseline aim. Floors and\n // ceilings stay — the margin aims higher, it never legalizes a harsher pick than the band allowed.\n const boost = (band: ContrastBand) => boostBand(band, lcBoost, lcAim);\n /* Pick from the TONAL ramp, never at the cost of the band's floor — see pickTonalInBand in\n lib/oklch-utils. Today no band needs the fallback (light body bottoms out at 75.4 Lc on the\n clipped ramp), but /colors lets a consumer re-base the ramp entirely, so the guard exists so\n the hue preference can never become a readability regression. */\n /* The adaptive twin, readableTonal, also lives in lib/oklch-utils: an unreachable aim makes\n readableForeground return the lightness EXTREME, where the gamut annihilates chroma, so every\n jewel's --foreground-opaque solved to pure black in light. Same clip, same ~1 Lc cost. */\n // Band-aware pick: honor each tier's floor (minimum) + ceiling (anti-spike), aiming for target. Normal\n // surfaces draw a COLORED pick from the theme ramp; `adaptive` (opaque floors) uses readableForeground\n // instead, which flips the lightness DIRECTION to whatever the floor needs — the ramp only spans the\n // readable half (white→base in dark mode), so it can't produce DARK text for a light floor (moonstone cream).\n const tier = (rawBand: ContrastBand) => {\n const band = boost(rawBand);\n return formatOklch(\n adaptive\n ? readableTonal(surface, band, {\n // Opaque cards on the hue-less themes (selenite + moonstone) follow the chosen accent too — same as\n // the normal surface above — so moonstone/selenite opaque-card text tints toward the accent.\n // Otherwise THIS surface's own composited hue, not the tint token's (see surfaceH): each\n // adaptive material composites a different stack, so each gets ink at its own angle.\n hue: huelessAccent ? accentH : surface.h,\n chroma: huelessAccent ? accentC : tintC > 0 ? cfgC : 0,\n // Accent path ONLY — the one case where the background CAN hold the kept chroma, so the\n // minChroma search does not bail to the extreme (see readableTonal).\n minChroma: huelessAccent ? 0.08 : 0,\n })\n : pickTonalInBand(ramp, surface, band),\n );\n };\n root.style.setProperty(`--foreground${suffix}`, tier(READABLE_USAGE.body));\n root.style.setProperty(\n `--muted-foreground${suffix}`,\n tier({\n // Raised from 45/60/75 → muted lands darker (≈L30 on a light opaque floor) — a firmer secondary,\n // not a faint one. Global + computed per-hue (moonstone, sistine, every jewel), both surfaces + opaque.\n floor: 60,\n target: 72,\n ceiling: 84,\n }),\n );\n // Size tiers default to a palette-ramp pick (tier). softHue/strongHue (mirroring iconHue) optionally\n // pin a tier to its OWN readable hue — a number, \"complement\" (theme's opposite, live), or null =\n // follow the palette — so headings / fine text can be tinted independently of body text.\n const tierAtHue = (usage: \"large\" | \"small\", choice: number | HarmonicName | null) =>\n choice == null\n ? tier(READABLE_USAGE[usage])\n : formatOklch(\n // Clipped like every other solve: a PINNED hue is the one case where collapsing to the\n // achromatic extreme is most obviously wrong — the whole point of pinning is to see it.\n readableTonal(surface, boost(READABLE_USAGE[usage]), {\n hue: typeof choice === \"string\" ? harmonicHue(fgHarmonyH, choice) : choice,\n chroma: 0.15,\n }),\n );\n root.style.setProperty(`--foreground-soft${suffix}`, tierAtHue(\"large\", storedFg.softHue));\n // REACH-LIMITED IN LIGHT MODE, by design of the surfaces themselves: the small band floors at Lc 90,\n // and a mid-light floor simply cannot deliver that much even with pure black text. Light opaque (L90)\n // tops out at 82.6–87.6 across the tints and light chakra (L88) at 82.0–83.7, so --foreground-strong\n // on those two surfaces lands 2.4–8.0 Lc under its floor. readableForeground's documented\n // reach < floor fallback covers it — it returns the MOST contrast available rather than failing —\n // and no margin can close the gap. Only raising --glass-opaque-l past ~94.5 / --glass-chakra-l past\n // ~92.5 would (measurably paler cards); that is a design call, not a banding bug. Dark mode has\n // headroom to spare (reach 96–104) and hits the floor everywhere.\n root.style.setProperty(`--foreground-strong${suffix}`, tierAtHue(\"small\", storedFg.strongHue));\n // Icons get their own foreground: a ui-band-legible color (lightness solved for contrast) at an\n // OPTIONAL chosen hue — so icons can be tinted/cycled while staying readable, independent of the\n // text palette. iconHue null → follow the theme (neutral → gray, tinted → the tint hue).\n // null → follow the theme, which means THIS surface's composited hue (see surfaceH), so an icon\n // sits at the same angle as the material behind it. A pinned number / harmonic is a deliberate\n // choice and overrides that.\n const iconH = typeof iconHue === \"string\" ? harmonicHue(fgHarmonyH, iconHue) : typeof iconHue === \"number\" ? iconHue : surface.h;\n root.style.setProperty(\n `--foreground-ui${suffix}`,\n formatOklch(\n readableTonal(surface, boost(READABLE_USAGE.ui), {\n hue: iconH,\n chroma: iconHue != null ? 0.15 : tintC > 0 ? cfgC : 0,\n }),\n ),\n );\n };\n\n // Normal surface: the veiled floor body text sits on (page + translucent/veiled cards), with the\n // show-through margin lifting each band target as the floor gets sheerer. Derived above the ramp\n // (see normalSurface) because the ramp's hue is read off it.\n applyTiers(normalSurface, \"\", false, normalLcBoost);\n /* Opaque cards paint --glass-opaque-bg, so band a second set against THAT — derived here from\n --glass-opaque-l / -c-max rather than reused from solidifyFloor. Those two used to be the same\n colour, and this call read `solidifyFloor` on exactly that basis; they are separate surfaces now\n (tokens.css), so sharing would band opaque-card text against the sheer backing instead of the\n card. In dark the two still resolve identically; in light the card stays at L88 while the backing\n sits at L92. Keep in step with tokens.css — an earlier version of this re-derivation drifted,\n carrying its own `* 0.9` multiplier (matching neither mode) and no chroma cap at all.\n `adaptive` so a LIGHT floor (moonstone cream) gets DARK text — the theme ramp only spans the\n readable half and can't. */\n applyTiers(\n {\n l: num(\"--glass-opaque-l\", dark ? 36.4 : 88),\n c: Math.min(tintC * num(\"--glass-opaque-c-scale\", dark ? 1.05 : 0.85), opaqueCMax),\n h: tintH,\n },\n \"-opaque\",\n true,\n 0, // no uncertainty — --glass-opaque-l models this floor exactly\n LC_AIM_KNOWN,\n );\n // Crystal cards: the specular gloss is baked UNDER content, so title-zone text sits on a locally\n // LIGHTENED surface — worst in dark mode, where the ~L94 highlight over a dark floor pulls the\n // local surface toward mid-gray. Band a THIRD set against the crystal surface + the title zone's\n // mean gloss term (see glossLayer). [data-material=\"crystal\"] and the crystal page style remap the\n // tiers to this set — except veiled crystal, whose floor is what the NORMAL tiers are banded for.\n // Same show-through margin logic: the backdrop's weight in this mix is (1−crysA)(1−tintA)(1−glossA).\n {\n const crysA = num(\"--glass-crystal-bg-a\", dark ? 0.1 : 0.3);\n // Layer order: --glass-crystal-bg (background-COLOR) → solidify → wash → gloss. The solidify step\n // was missing, which is what put moonstone-night crystal 27.5 L below what it paints. Each layer\n // now composites in sRGB (see compositeSurface) rather than lerping OKLCH coordinates — on this\n // surface that error CHANGED SIGN by preset, so nothing shorter than a real composite fixes it.\n const uCrystal = Math.min(Math.max((1 - crysA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n // --glass-crystal-bg, engine.css: oklch(--glass-crystal-l, --glass-tint-c-hi × 0.6). This\n // modeled a flat L100 at the RAW tint chroma — 100 carries no chroma at all (which is why\n // the token pins 96), and skipping the -c-hi cap asked for up to 6× the colour that\n // renders (tourmaline 0.0636 vs the 0.0102 ceiling).\n {\n l: num(\"--glass-crystal-l\", 96),\n c: tintCHi * 0.6,\n h: tintH,\n a: crysA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-crystal\",\n true,\n LC_MARGIN * uCrystal,\n );\n }\n // Chakra cards: content sits on the translucent body, banded against the whole stack it paints.\n // The facet bands need no term — they live in box-shadow, ride the outer few px of the edge, and\n // pair a highlight against an ink on opposite sides, so they contribute nothing where text\n // actually sits. Show-through is (1 − body a)(1 − tintA)(1 − glossA)(1 − glassOpacity) — the same\n // four-term form crystal uses, since chakra bakes the same gloss. `adaptive` for the same reason\n // opaque is: an L88 body can sit on a dark page, and the theme ramp only spans the readable half.\n {\n const bodyA = num(\"--glass-chakra-a\", dark ? 0.58 : 0.62);\n /* --glass-chakra-bg is the background-COLOR and it is TRANSLUCENT (--glass-chakra-a, 0.62 light\n / 0.58 dark), so --glass-chakra-l was never the banding lightness on its own — the page shows\n through it. And --glass-chakra-stack-bg is the SAME stack crystal bakes (gloss triple, fresco\n slot, wash layer) with solidify composed under it, so chakra takes the tint wash and the gloss\n too. Modeling only `chakra-l → solidify` dropped the page, the body's own alpha, the entire\n wash and the gloss; it agreed with the truth on moonstone night by coincidence, because that\n preset pins --glass-chakra-l (84) near its cream opaque floor. Full stack, real paint order. */\n /* (1 − GLOSS_TOP_A) belongs here for the same reason it does on crystal: show-through is the\n backdrop's SURVIVING weight, so every layer painted above it attenuates — and chakra bakes the\n gloss now. Omitting it overstated the backdrop's share by 1/(1−0.2) = 1.25×, which inflated an\n UNCERTAINTY margin (the boost is LC_MARGIN × this) on a surface that is in fact better known\n than the model claimed. */\n const uChakra = Math.min(Math.max((1 - bodyA) * (1 - tintA) * (1 - GLOSS_TOP_A) * (1 - glassOpacity), 0), 1);\n applyTiers(\n compositeSurface(\n {\n l: dark ? 20 : 95,\n c: 0,\n h: tintH,\n },\n [\n {\n l: num(\"--glass-chakra-l\", dark ? 28 : 88),\n c: Math.min(tintC, num(\"--glass-chakra-c-max\", dark ? 0.046 : 0.055)),\n h: tintH,\n a: bodyA,\n },\n solidifyLayer,\n {\n l: washL,\n c: tintC * washCMult,\n h: tintH,\n a: tintA,\n },\n glossLayer,\n ],\n ),\n \"-chakra\",\n true,\n LC_MARGIN * uChakra,\n );\n }\n if (dbg) {\n const dur = performance.now() - t0;\n // Wall-time = the getComputedStyle forced recalc + the JS solve (58 µs). The post-write recalc from\n // the setProperty calls shows separately as \"Recalculate Style\" in the Performance panel.\n console.debug(\n `[AutoForeground] update ${dur.toFixed(2)}ms · --foreground ${fgBefore || \"(unset)\"} → ${root.style.getPropertyValue(\"--foreground\")}`,\n );\n try {\n performance.measure(\"AutoForeground.update\", {\n start: t0,\n duration: dur,\n });\n } catch {\n // options form of performance.measure not supported\n }\n }\n };\n\n update();\n // Mode toggle (class) uses the DOM-read fallback — the one place we still pay the recalc, by design.\n // Tint / accent / lightness changes arrive via FG_EVENT carrying a JS snapshot (no getComputedStyle).\n // Inline STYLE mutations re-band ONLY when a surface-model input actually changed: --glass-solid-a\n // (the veil-floor slider) or --glass-gloss-l (the crystal gloss-boldness slider) — the two inputs\n // with no FG_EVENT. The old-vs-new gate keeps tint drags on the event fast path AND breaks the\n // self-trigger loop from our own --foreground* writes (which touch neither).\n const STYLE_INPUTS = [\n /--glass-solid-a:\\s*([^;]+)/,\n /--glass-gloss-l:\\s*([^;]+)/,\n // --glass-opacity is a surface-model input now that the solidify floor is banded against, and the\n // component-opacity slider writes it inline with no FG_EVENT — without this the tiers go stale the\n // moment a consumer dials solidity, which is exactly when the floor moves most.\n /--glass-opacity:\\s*([^;]+)/,\n ];\n const observer = new MutationObserver((muts) => {\n for (const m of muts) {\n if (m.attributeName === \"class\") {\n update();\n return;\n }\n if (m.attributeName === \"style\") {\n const now = root.getAttribute(\"style\") ?? \"\";\n const was = m.oldValue ?? \"\";\n if (STYLE_INPUTS.some((re) => re.exec(was)?.[1]?.trim() !== re.exec(now)?.[1]?.trim())) {\n update();\n return;\n }\n }\n }\n });\n observer.observe(root, {\n attributes: true,\n attributeOldValue: true,\n attributeFilter: [\n \"class\",\n \"style\",\n ],\n });\n const onFg = (e: Event) => update((e as CustomEvent>).detail ?? undefined);\n window.addEventListener(FG_EVENT, onFg);\n return () => {\n observer.disconnect();\n window.removeEventListener(FG_EVENT, onFg);\n };\n }, [\n paletteProp,\n rl,\n rc,\n rh,\n rcount,\n ]);\n\n return null;\n}\n", "type": "registry:component", "target": "components/auto-foreground.tsx" } diff --git a/public/r/oklch-utils.json b/public/r/oklch-utils.json index 9d03a79..049b3ff 100644 --- a/public/r/oklch-utils.json +++ b/public/r/oklch-utils.json @@ -8,7 +8,7 @@ "files": [ { "path": "lib/oklch-utils.ts", - "content": "/**\n * OKLCH color primitives — parse / format an oklch string and generate symmetric ramps\n * (hue or chroma) around an existing color. Dependency-free; shares the {l, c, h} shape with\n * `GradientColor` in gradient-utils.ts.\n */\n\nexport interface OklchColor {\n /** Lightness, 0–100 (%) */\n l: number;\n /** Chroma, 0–~0.37 */\n c: number;\n /** Hue, 0–360 (degrees) */\n h: number;\n /** Optional alpha, 0–1 */\n alpha?: number;\n}\n\n/**\n * Practical oklch chroma ceiling. Both sRGB and Display-P3 colors stay below ~0.37 (per Evil\n * Martians; confirmed by sweeping every L×hue with the gamut math below — sRGB max ≈ 0.321,\n * P3 max ≈ 0.363). Used as the binary-search bound and the chroma-ramp default range.\n */\nexport const MAX_CHROMA = 0.37;\n\n/** Wrap a hue into [0, 360). */\nexport function wrapHue(h: number): number {\n return ((h % 360) + 360) % 360;\n}\n\n/** Clamp chroma into [0, MAX_CHROMA]. */\nexport function clampChroma(c: number): number {\n return Math.max(0, Math.min(MAX_CHROMA, c));\n}\n\n/** A bare lightness ≤ 1 is the 0–1 form (×100); otherwise it's already 0–100. */\nfunction normalizeLightness(l: number): number {\n return l <= 1 ? l * 100 : l;\n}\n\n/**\n * Parse an `oklch()` string into components. Handles both lightness conventions used in this\n * repo — `oklch(72% 0.05 255)` and `oklch(0.72 0.05 255)` — plus an optional `/ alpha`.\n * Returns null if the string isn't a parseable oklch().\n */\nexport function parseOklch(input: string): OklchColor | null {\n const match = /^\\s*oklch\\(([^)]+)\\)\\s*$/i.exec(input);\n if (!match) return null;\n\n const [coords, alphaPart] = match[1].split(\"/\");\n const parts = coords.trim().split(/\\s+/);\n if (parts.length < 3) return null;\n\n const lRaw = parts[0];\n const l = lRaw.endsWith(\"%\") ? Number.parseFloat(lRaw) : normalizeLightness(Number.parseFloat(lRaw));\n const c = Number.parseFloat(parts[1]);\n const h = Number.parseFloat(parts[2]);\n if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) return null;\n\n const color: OklchColor = {\n l,\n c,\n h,\n };\n if (alphaPart !== undefined) {\n const a = Number.parseFloat(alphaPart.trim());\n if (Number.isFinite(a)) color.alpha = a;\n }\n return color;\n}\n\n/** Format components back into an `oklch()` string (lightness as %). */\nexport function formatOklch(color: OklchColor, alpha?: number): string {\n const a = alpha ?? color.alpha;\n const base = `${color.l.toFixed(1)}% ${color.c.toFixed(3)} ${color.h.toFixed(1)}`;\n return a === undefined ? `oklch(${base})` : `oklch(${base} / ${a})`;\n}\n\n/** Coerce a base argument (string | OklchColor) into components; throws on an unparseable string. */\nfunction toColor(base: OklchColor | string): OklchColor {\n if (typeof base !== \"string\") return base;\n const parsed = parseOklch(base);\n if (!parsed) throw new Error(`oklch-utils: could not parse \"${base}\"`);\n return parsed;\n}\n\n/** Per-side step count, clamped to a sane [3, 12] (rounded). */\nfunction clampCount(count: number): number {\n return Math.max(3, Math.min(12, Math.round(count)));\n}\n\n/**\n * A full-range value ramp: `count` steps each side of `seed`, reaching `min` on the left and\n * `max` on the right, with the seed held at the center index. Step sizes differ per side (the\n * seed is rarely the midpoint), so the whole [min, max] range is always covered — more steps\n * just sample it finer. Returns `2 * count + 1` values, min → seed → max.\n */\nfunction rangeRamp(seed: number, min: number, max: number, count: number): number[] {\n const leftStep = (seed - min) / count;\n const rightStep = (max - seed) / count;\n const out: number[] = [];\n for (let i = count; i >= 1; i--) out.push(seed - leftStep * i);\n out.push(seed);\n for (let i = 1; i <= count; i++) out.push(seed + rightStep * i);\n return out;\n}\n\n/**\n * Hue ramp covering the FULL wheel: `count` steps each side, the seed centered, hues spread evenly\n * around the wheel (step = 360 / (2·count + 1)) and wrapped into [0, 360). Lightness + chroma are\n * held; `count` clamped to [3, 12]. Unlike chroma/lightness, hue is cyclic — 0° ≡ 360° — so the ramp\n * is distributed cyclically instead of running to both endpoints, which keeps the two edge swatches\n * distinct. e.g. seed 120, count 4 → 320, 0, 40, 80, 120, 160, 200, 240, 280.\n */\nexport function hueRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n const n = clampCount(count);\n const seed = wrapHue(color.h);\n const step = 360 / (2 * n + 1);\n const out: OklchColor[] = [];\n for (let k = -n; k <= n; k++) {\n out.push({\n ...color,\n h: wrapHue(seed + k * step),\n });\n }\n return out;\n}\n\n/** Full-wheel hue ramp as CSS oklch strings (the seed sits at the center index). */\nexport function hueRamp(base: OklchColor | string, count: number): string[] {\n return hueRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Hue harmonies ─────────────────────────────────────────────────────────────\n// Rotate the base hue to derive complementary / harmonious colors (lightness + chroma held). oklch\n// hue is perceptually even, so a 180° \"complement\" is a balanced opposite — not the skewed HSL/RGB one.\n\n/** The perceptual complement: same L + C, hue rotated 180° (alpha preserved), gamut-clamped — the\n * 180° case of {@link harmony}. */\nexport function complement(base: OklchColor | string, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n return harmony(\n base,\n [\n 180,\n ],\n gamut,\n )[1];\n}\n\n/**\n * Hue-harmony set: the base plus a color at each rotation `angle` in degrees (same L + C),\n * gamut-clamped. Returns `[base, …rotations]`. Common sets — analogous `[-30, 30]`, triadic\n * `[120, 240]`, split-complement `[150, 210]`, tetradic `[90, 180, 270]`.\n */\nexport function harmony(base: OklchColor | string, angles: number[], gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n const c = toColor(base);\n return [\n clampToGamut(c, gamut),\n ...angles.map((a) =>\n clampToGamut(\n {\n ...c,\n h: wrapHue(c.h + a),\n },\n gamut,\n ),\n ),\n ];\n}\n\n/** Named color-wheel relationships — the JS mirror of the --hue-* tokens in app/globals.css. */\nexport type HarmonicName =\n | \"base\"\n | \"complement\"\n | \"analogous-1\"\n | \"analogous-2\"\n | \"split-1\"\n | \"split-2\"\n | \"triad-1\"\n | \"triad-2\"\n | \"tetrad-1\"\n | \"tetrad-2\"\n | \"tetrad-3\"\n | \"square-1\"\n | \"square-2\"\n | \"square-3\";\n\n/**\n * Hue offsets (degrees) for each color-wheel relationship. MUST stay in lockstep with the `--hue-*`\n * tokens in app/globals.css (`calc(var(--harmony-h) + N)`) so a JS-solved icon/foreground hue lands on\n * the SAME angle as the CSS harmonic swatches. Pair with `harmonicHue()` + `readableForeground()` to get\n * a contrast-solved (APCA/ARC) color at the harmonic angle.\n */\nexport const HARMONIC_OFFSETS: Record = {\n base: 0,\n complement: 180,\n \"analogous-1\": -30,\n \"analogous-2\": 30,\n \"split-1\": 150,\n \"split-2\": 210,\n \"triad-1\": 120,\n \"triad-2\": 240,\n \"tetrad-1\": 60,\n \"tetrad-2\": 180,\n \"tetrad-3\": 240,\n \"square-1\": 90,\n \"square-2\": 180,\n \"square-3\": 270,\n};\n\n/** All harmonic relationship names, in wheel order (for building pickers). */\nexport const HARMONIC_NAMES = Object.keys(HARMONIC_OFFSETS) as HarmonicName[];\n\n/**\n * Rotate an anchor hue by a named color-wheel relationship, wrapped to [0, 360). The anchor is normally\n * the harmony hue (--harmony-h: the content hue, or 0 for the hue-less selenite/moonstone themes). Feed the\n * result to `readableForeground({ hue })` to keep the contrast solve while landing on the harmonic angle.\n */\nexport function harmonicHue(anchorH: number, name: HarmonicName): number {\n return wrapHue(anchorH + HARMONIC_OFFSETS[name]);\n}\n\n/**\n * Chroma ramp covering [0, cap]: `count` steps each side, the seed centered (left → 0, right →\n * cap). Lightness + hue are held; `count` clamped to [3, 12]. `max` defaults to the largest chroma\n * actually DISPLAYABLE for this L+hue in sRGB (≈0.32, varies by L/hue) — so the ramp reaches the\n * visible edge instead of a flat MAX_CHROMA that just clamps on screen. Pass `maxP3Chroma(l, h)` on\n * P3-capable displays for a punchier top end, or any explicit number to sweep a custom range.\n */\nexport function chromaRampColors(base: OklchColor | string, count: number, max?: number): OklchColor[] {\n const color = toColor(base);\n const cap = max ?? maxSrgbChroma(color.l, color.h);\n const seed = Math.max(0, Math.min(cap, color.c));\n return rangeRamp(seed, 0, cap, clampCount(count)).map((c) => ({\n ...color,\n c,\n }));\n}\n\n/** Chroma ramp as CSS oklch strings (the seed sits at the center index). */\nexport function chromaRamp(base: OklchColor | string, count: number, max?: number): string[] {\n return chromaRampColors(base, count, max).map((color) => formatOklch(color));\n}\n\n/**\n * Lightness ramp covering the FULL range [0, 100]: `count` steps each side, the seed centered\n * (left → 0, right → 100). Chroma + hue are held; `count` clamped to [3, 12].\n */\nexport function lightnessRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n return rangeRamp(color.l, 0, 100, clampCount(count)).map((l) => ({\n ...color,\n l,\n }));\n}\n\n/** Full-range lightness ramp as CSS oklch strings (the seed sits at the center index). */\nexport function lightnessRamp(base: OklchColor | string, count: number): string[] {\n return lightnessRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Gamut-aware tonal scale ───────────────────────────────────────────────────\n// A tonal scale (the Radix/Tailwind 1→N pattern) is NOT a chroma shift: lightness eases\n// light→dark while chroma rises with the scale but is capped by the sRGB gamut — so chroma is\n// near-zero in the light tints, peaks in the mid-dark \"brand\" steps, then tapers in the darkest\n// steps (because dark colors can't physically hold as much chroma). The gamut cap is what\n// produces that peak-then-fall curve; a flat chroma shift cannot.\n\n/** OKLCH (l 0–100) → linear sRGB channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearSrgb(\n l: number,\n c: number,\n hDeg: number,\n): [\n number,\n number,\n number,\n] {\n const L = l / 100;\n const hr = (hDeg * Math.PI) / 180;\n const a = c * Math.cos(hr);\n const b = c * Math.sin(hr);\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.291485548 * b;\n const ll = l_ * l_ * l_;\n const mm = m_ * m_ * m_;\n const ss = s_ * s_ * s_;\n return [\n 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss,\n -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss,\n -0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the sRGB gamut. */\nexport function inSrgbGamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearSrgb(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** OKLCH (l 0–100) → linear Display-P3 channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearP3(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n // linear sRGB → linear Display-P3 (same color, wider basis) — valid even for out-of-sRGB values.\n const [sr, sg, sb] = oklchToLinearSrgb(l, c, h);\n return [\n 0.8224621 * sr + 0.177538 * sg,\n 0.0331942 * sr + 0.9668058 * sg,\n 0.0170608 * sr + 0.072374 * sg + 0.9105652 * sb,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the Display-P3 gamut. */\nexport function inP3Gamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearP3(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** Largest in-gamut chroma for (l, h) under the given gamut test, via binary search. */\nfunction maxChromaFor(l: number, h: number, test: (l: number, c: number, h: number) => boolean): number {\n let lo = 0;\n let hi = MAX_CHROMA;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (test(l, mid, h)) lo = mid;\n else hi = mid;\n }\n // Sit 3% inside the boundary. A color exactly at the gamut edge renders fine in Chrome (CSS Color 4\n // hue-preserving gamut mapping) but Safari per-channel-clamps it toward GREY — so edge-chroma\n // foregrounds/icons went grey in Safari while staying tinted in Chrome. This margin keeps every emitted\n // color clearly in-gamut, so both engines show the same tint.\n return lo * 0.97;\n}\n\n/** Largest chroma keeping (l, h) inside the sRGB gamut. */\nexport function maxSrgbChroma(l: number, h: number): number {\n return maxChromaFor(l, h, inSrgbGamut);\n}\n\n/** Largest chroma keeping (l, h) inside the Display-P3 gamut. */\nexport function maxP3Chroma(l: number, h: number): number {\n return maxChromaFor(l, h, inP3Gamut);\n}\n\n/** Reduce a color's chroma until it fits the given gamut (lightness + hue preserved). */\nexport function clampToGamut(color: OklchColor, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n const max = gamut === \"p3\" ? maxP3Chroma(color.l, color.h) : maxSrgbChroma(color.l, color.h);\n return {\n ...color,\n c: Math.min(color.c, max),\n };\n}\n\nexport interface TonalScaleOptions {\n /** Fixed hue, 0–360. */\n hue: number;\n /** Number of steps. Default 12. */\n steps?: number;\n /** Lightness (0–1) at the lightest step. Default 0.98. */\n lightest?: number;\n /** Lightness (0–1) at the darkest step. Default 0.35. */\n darkest?: number;\n /** Peak chroma intent — the scale builds toward this (gamut permitting). Default 0.2. */\n chroma?: number;\n /** Easing exponent for the lightness descent (>1 stays light longer). Default 2.2. */\n lightEase?: number;\n /** Easing exponent for the chroma rise (>1 stays subtle longer). Default 1.1. */\n chromaRise?: number;\n /** Fraction of the in-gamut chroma ceiling to use, for headroom. Default 0.94. */\n gamutFraction?: number;\n /** Cap chroma to a gamut envelope (gives the natural peak + dark falloff). `\"p3\"` allows a\n * punchier peak on wide-gamut displays; `false` disables clamping. Default `\"srgb\"`. */\n gamut?: \"srgb\" | \"p3\" | false;\n}\n\n/**\n * Generate a tonal color scale — a single hue with lightness eased light→dark and chroma that\n * rises with the scale but is capped by the chosen gamut (sRGB by default; pass `gamut: \"p3\"` for a\n * punchier wide-gamut peak). Returns lightest → darkest as OklchColor[].\n * `tonalScaleColors({ hue: 252 })` gives a Radix/Tailwind-shaped 12-step blue scale.\n */\nexport function tonalScaleColors(options: TonalScaleOptions): OklchColor[] {\n const {\n hue,\n steps = 12,\n lightest = 0.98,\n darkest = 0.35,\n chroma = 0.2,\n lightEase = 2.2,\n chromaRise = 1.1,\n gamutFraction = 0.94,\n gamut = \"srgb\",\n } = options;\n\n const ceiling = gamut === \"p3\" ? maxP3Chroma : maxSrgbChroma;\n const n = Math.max(2, Math.round(steps));\n const out: OklchColor[] = [];\n for (let i = 0; i < n; i++) {\n const p = i / (n - 1);\n const l = (lightest - (lightest - darkest) * p ** lightEase) * 100;\n let c = chroma * p ** chromaRise;\n if (gamut) c = Math.min(c, ceiling(l, hue) * gamutFraction);\n out.push({\n l,\n c: clampChroma(c),\n h: hue,\n });\n }\n return out;\n}\n\n/** Which ramp drives a {@link rampGradient}. */\nexport type RampGradientAxis = \"hue\" | \"lightness\" | \"tonal\" | \"chroma\";\n\n/** Gradient painting shape — same color-stop list, different geometry. */\nexport type GradientShape = \"linear\" | \"radial\" | \"conic\";\n\n/** Geometry knobs for {@link wrapGradient}: `angle` (linear direction / conic `from` rotation), `position`\n * (radial + conic center, e.g. `\"50% 50%\"`, `\"top left\"`), and the radial `shape` / `size`. All optional;\n * the defaults reproduce a centered circle / 90° linear (so omitting them is a no-op). */\nexport interface GradientGeometry {\n angle?: number;\n position?: string;\n radialShape?: \"circle\" | \"ellipse\";\n radialSize?: \"closest-side\" | \"closest-corner\" | \"farthest-side\" | \"farthest-corner\";\n}\n\n/** Wrap a `` in the chosen gradient function (all interpolate `in oklch`). Linear uses the\n * angle as its direction; conic as the `from` rotation about `position`; radial paints a `shape`+`size`\n * centered at `position` (angle unused). */\nexport function wrapGradient(shape: GradientShape, stops: string, geom: GradientGeometry = {}): string {\n const { angle = 90, position = \"50% 50%\", radialShape = \"circle\", radialSize = \"farthest-corner\" } = geom;\n switch (shape) {\n case \"radial\":\n return `radial-gradient(${radialShape} ${radialSize} at ${position} in oklch, ${stops})`;\n case \"conic\":\n return `conic-gradient(from ${angle}deg at ${position} in oklch, ${stops})`;\n default:\n return `linear-gradient(${angle}deg in oklch, ${stops})`;\n }\n}\n\n/**\n * Build a CSS `linear-gradient` from one of the ramps, the seed centered as a slightly wider plateau\n * so the theme color anchors the middle; the rest sit in equal-width bands left → right. `count`\n * steps each side (clamped [3,8]). Interpolated `in oklch` for a perceptual blend.\n * e.g. rampGradient(\"tonal\", { l: 62, c: 0.15, h: 250 }, 5).\n */\n/**\n * THE one axis → colors mapping every backdrop shares (CSS gradient background, canvas background,\n * docs demos), seed-centered with `count` steps per side. Single-sourced so an axis can't mean\n * different math in different engines: `tonal` is the seed's own lightness ramp, gamut-clamped\n * (theme-true — the pure-CSS gradient's original definition), NOT the fixed-chroma tonal scale.\n */\nexport function rampAxisColors(axis: RampGradientAxis, seed: OklchColor, count: number, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n switch (axis) {\n case \"hue\":\n return hueRampColors(seed, count);\n case \"lightness\":\n return lightnessRampColors(seed, count);\n case \"chroma\":\n return chromaRampColors(seed, count, gamut === \"p3\" ? maxP3Chroma(seed.l, seed.h) : maxSrgbChroma(seed.l, seed.h));\n default:\n return lightnessRampColors(seed, count).map((color) => clampToGamut(color, gamut));\n }\n}\n\n/**\n * The lightness range a full-bleed BACKDROP may use while text over it stays readable.\n *\n * The ramps below deliberately cover their whole range — that is what makes them useful as swatches.\n * As a wallpaper it is the opposite of what you want: a lightness ramp runs L 0 → 100, so a single\n * solved foreground is legible at one end of the viewport and invisible at the other. This returns\n * the sub-range on the far side of `fg` where |Lc| stays at or above `targetLc`, measured with the\n * same APCA implementation the rest of the theme uses.\n *\n * Default target is 60 — APCA's non-body level. Body copy sits on veiled glass ABOVE the backdrop and\n * clears 75 comfortably from there; 60 is for the headings that sit directly on it. Asking 75 of the\n * raw backdrop would squeeze light mode into L 87–100 and leave nothing to make a gradient out of.\n *\n * |Lc| is monotonic in backdrop lightness on either side of a foreground, so this binary-searches the\n * boundary rather than sweeping.\n */\nexport function readableLightnessBand(\n fg: OklchColor | string,\n seed: OklchColor,\n targetLc = 60,\n headroom = 7,\n): {\n lMin: number;\n lMax: number;\n} {\n const text = toColor(fg);\n const passes = (l: number) =>\n Math.abs(\n apcaContrast(text, {\n ...seed,\n l,\n }),\n ) >= targetLc;\n /* A dark foreground pushes the backdrop light, and vice versa. */\n const backdropGoesLight = text.l < 50;\n let lo = backdropGoesLight ? 0 : 100;\n let hi = backdropGoesLight ? 100 : 0;\n if (!passes(hi)) {\n /* Nothing on this side clears the target (a mid-lightness foreground). Give back the whole\n range rather than an empty one — banding is a readability aid, not a hard gate. */\n return {\n lMin: 0,\n lMax: 100,\n };\n }\n for (let i = 0; i < 12; i++) {\n const mid = (lo + hi) / 2;\n if (passes(mid)) hi = mid;\n else lo = mid;\n }\n /* Stop `headroom` short of the absolute extreme. That end is the HIGH-contrast one, so backing off\n it costs no readability — but L 0 and L 100 are the two lightnesses that hold no chroma at all, so\n running to them washes the tint out of one edge of the wallpaper. Never let it cross the boundary. */\n const edge = Math.ceil(hi);\n return backdropGoesLight\n ? {\n lMin: edge,\n lMax: Math.max(edge + 1, 100 - headroom),\n }\n : {\n lMin: Math.min(Math.floor(hi) - 1, headroom),\n lMax: Math.floor(hi),\n };\n}\n\n/** Remap a ramp's lightnesses into `band`, preserving the ramp's shape. A ramp whose lightness is\n * constant (the hue and chroma axes) is clamped into the band instead of stretched across it.\n * Exported so the canvas backdrop bands the same ramps the CSS gradient does. */\nexport function fitToBand(\n colors: OklchColor[],\n band: {\n lMin: number;\n lMax: number;\n },\n): OklchColor[] {\n const ls = colors.map((c) => c.l);\n const lo = Math.min(...ls);\n const hi = Math.max(...ls);\n const span = hi - lo;\n if (span < 0.001) {\n const l = Math.min(band.lMax, Math.max(band.lMin, lo));\n return colors.map((c) => ({\n ...c,\n l,\n }));\n }\n return colors.map((c) => ({\n ...c,\n l: band.lMin + ((c.l - lo) / span) * (band.lMax - band.lMin),\n }));\n}\n\n/* Frescoes are authored as fixed multi-hue stops at one lightness, so they skip the ramp entirely —\n which also means they skip its banding and its conic loop-close. Apply both here. */\nexport function bandedFrescoStops(\n colors: string[],\n band:\n | {\n lMin: number;\n lMax: number;\n }\n | undefined,\n shape: GradientShape,\n): string {\n let out = colors;\n if (band) {\n out = out.map((css) => {\n const parsed = parseOklch(css);\n if (!parsed) return css;\n return formatOklch({\n ...parsed,\n l: Math.min(band.lMax, Math.max(band.lMin, parsed.l)),\n });\n });\n }\n if (shape === \"conic\" && out.length > 1) {\n const loop = [\n ...out,\n out[0],\n ];\n return loop.map((css, i) => `${css} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`).join(\", \");\n }\n return out.join(\", \");\n}\n\nexport function rampGradient(\n axis: RampGradientAxis,\n seed: OklchColor,\n count: number,\n options: {\n gamut?: \"srgb\" | \"p3\";\n shape?: GradientShape;\n /** Constrain the ramp's lightness to a readable range — see {@link readableLightnessBand}. */\n band?: {\n lMin: number;\n lMax: number;\n };\n } & GradientGeometry = {},\n): string {\n const { gamut = \"srgb\", shape = \"linear\", band, angle, position, radialShape, radialSize } = options;\n const ramped = rampAxisColors(axis, seed, count, gamut);\n const colors = band ? fitToBand(ramped, band) : ramped;\n\n /* A conic gradient wraps: whatever sits at 360° butts straight into 0°. An open ramp therefore\n meets its own opposite end at the twelve-o'clock line and draws a hard seam right through the\n wallpaper — at full range that is white against black. Close the loop instead, and drop the\n center plateau, which reads as a lopsided wedge once the ramp is bent into a circle.\n hue — already cyclic, so repeating the first color at 360° spaces evenly and joins invisibly.\n other — mirror out and back, giving a symmetric sweep that also ends where it started. */\n if (shape === \"conic\") {\n const loop =\n axis === \"hue\"\n ? [\n ...colors,\n colors[0],\n ]\n : [\n ...colors,\n ...colors.slice(0, -1).reverse(),\n ];\n const stops = loop.map((color, i) => `${formatOklch(color)} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`);\n return wrapGradient(shape, stops.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n }\n\n const mid = Math.floor(colors.length / 2);\n const plateau = 7; // half-width (%) of the centered theme-color band\n const leftEnd = 50 - plateau;\n const rightStart = 50 + plateau;\n const parts = colors.map((color, i) => {\n const css = formatOklch(color);\n if (i === mid) return `${css} ${leftEnd}%, ${css} ${rightStart}%`;\n const pos = i < mid ? (i / mid) * leftEnd : rightStart + ((i - mid) / mid) * (100 - rightStart);\n return `${css} ${pos.toFixed(1)}%`;\n });\n return wrapGradient(shape, parts.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n}\n\n// ── APCA contrast (APCA-W3 / ARC) ─────────────────────────────────────────────\n// Inlined port of the APCA-W3 reference (© Andrew Somers / Myndex Research, licensed to the W3/AGWG).\n// The APCA constants below are the LOCKED beta set (0.1.x) and are used UNMODIFIED. APCA/ARC measures\n// readability contrast — it is NOT a means to claim WCAG 2 conformance. Verified to match apca-w3's\n// APCAcontrast() — see scripts/apca-oracle.mjs. Lc is signed: positive = dark text on a light\n// background, negative = light text on a dark one; |Lc| is the perceptual level (~45 large/UI, ~60\n// other content, ~75 body, ~90 fine). Polarity-aware — which WCAG-2's ratio is not — so it fits dark\n// mode + tinted glass far better.\n\n/** OKLCH (l 0–100) → gamma-encoded sRGB channels in [0, 1] (out-of-gamut values are clipped). */\nexport function oklchToSrgb(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n const [lr, lg, lb] = oklchToLinearSrgb(l, c, h);\n const encode = (v: number) => {\n const x = Math.max(0, Math.min(1, v));\n return x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055;\n };\n return [\n encode(lr),\n encode(lg),\n encode(lb),\n ];\n}\n\n/** Gamma-encoded sRGB channels in [0, 1] → OKLCH (l 0–100). Exact inverse of {@link oklchToSrgb} for\n * in-gamut input, so a composite→decompose round trip is lossless. */\nexport function srgbToOklch([r, g, b]: [\n number,\n number,\n number,\n]): OklchColor {\n const decode = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const [lr, lg, lb] = [\n decode(r),\n decode(g),\n decode(b),\n ];\n const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);\n const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);\n const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);\n const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;\n const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;\n const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;\n return {\n l: L * 100,\n c: Math.hypot(a, bb),\n h: wrapHue((Math.atan2(bb, a) * 180) / Math.PI),\n };\n}\n\n/**\n * Composite a stack of translucent OKLCH layers THE WAY THE BROWSER DOES — CSS source-over on\n * gamma-encoded sRGB channels — and report the result in OKLCH.\n *\n * The obvious shortcut is to lerp the OKLCH coordinates instead (`l1·(1−a) + l2·a`, same for chroma).\n * That is a different operation and it is measurably wrong: OKLCH lightness is a cube-root-ish\n * perceptual scale, sRGB is a ~2.2 gamma, and chroma is a POLAR coordinate, so lerping it mixes two\n * hues along a straight line through the chroma plane rather than around it. Measured against the\n * shipped presets, the shortcut overstated light-mode body contrast by up to 2.8 Lc on the veiled\n * surface — enough to hide four presets sitting under the ARC body floor — while on the crystal\n * surface the error CHANGES SIGN by preset (+0.95 carnelian, −2.66 aventurine). That sign flip is why\n * this is a compositing fix and not a fudge factor: no single correction term can be both.\n *\n * `layers` paint bottom-first; `base` is fully opaque. Each layer's alpha is its own (a layer at\n * alpha 1 simply replaces what is under it). Hue is carried through from the top-most layer that\n * actually contributed chroma, so a neutral stack keeps its nominal hue instead of collapsing to 0.\n */\nexport function compositeSurface(\n base: OklchColor,\n layers: Array<\n OklchColor & {\n a: number;\n }\n >,\n): OklchColor {\n let px = oklchToSrgb(base.l, base.c, base.h);\n let hue = base.h;\n for (const layer of layers) {\n const a = Math.min(Math.max(layer.a, 0), 1);\n if (a <= 0) continue;\n const src = oklchToSrgb(layer.l, layer.c, layer.h);\n px = [\n src[0] * a + px[0] * (1 - a),\n src[1] * a + px[1] * (1 - a),\n src[2] * a + px[2] * (1 - a),\n ];\n if (layer.c > 0) hue = layer.h;\n }\n const out = srgbToOklch(px);\n // Below this the hue angle is numerical noise off a near-neutral pixel — keep the stack's nominal\n // hue so callers that tint from the surface (readableForeground, the ramp base) stay on the theme.\n return out.c < 1e-4\n ? {\n ...out,\n h: hue,\n }\n : out;\n}\n\n/** APCA screen luminance (Ys) from gamma-encoded sRGB. */\nfunction apcaLuminance([r, g, b]: [\n number,\n number,\n number,\n]): number {\n return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;\n}\n\n/**\n * APCA lightness contrast (Lc) between a text color and a background color. Signed: positive = dark\n * text on a light bg, negative = light text on a dark bg; use |Lc| for the level (~60 headings,\n * ~75 body text). A dependency-free port of APCA-W3 0.1.9.\n */\nexport function apcaContrast(text: OklchColor | string, bg: OklchColor | string): number {\n const t = toColor(text);\n const b = toColor(bg);\n let txtY = apcaLuminance(oklchToSrgb(t.l, t.c, t.h));\n let bgY = apcaLuminance(oklchToSrgb(b.l, b.c, b.h));\n const blkThrs = 0.022;\n // biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA blkClmp tuning constant, not √2\n const blkClmp = 1.414;\n txtY = txtY > blkThrs ? txtY : txtY + (blkThrs - txtY) ** blkClmp;\n bgY = bgY > blkThrs ? bgY : bgY + (blkThrs - bgY) ** blkClmp;\n if (Math.abs(bgY - txtY) < 0.0005) return 0;\n let lc: number;\n if (bgY > txtY) {\n const sapc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;\n lc = sapc < 0.1 ? 0 : sapc - 0.027;\n } else {\n const sapc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;\n lc = sapc > -0.1 ? 0 : sapc + 0.027;\n }\n return lc * 100;\n}\n\nconst FG_LIGHT: OklchColor = {\n l: 100,\n c: 0,\n h: 0,\n};\nconst FG_DARK: OklchColor = {\n l: 15,\n c: 0,\n h: 0,\n};\n\n/**\n * Pick whichever foreground has the higher APCA contrast on `bg` — by default near-white vs the\n * near-black `--foreground`. The light, perceptual way to choose a readable text/icon color: cheap\n * enough to run on a theme/tint change (microseconds), no per-frame work, no canvas readback.\n */\nexport function pickForeground(bg: OklchColor | string, light: OklchColor = FG_LIGHT, dark: OklchColor = FG_DARK): OklchColor {\n return Math.abs(apcaContrast(light, bg)) >= Math.abs(apcaContrast(dark, bg)) ? light : dark;\n}\n\n/**\n * From `ramp`, prefer colors whose |APCA Lc| on `surface` falls within [floor, ceiling] and sit closest\n * to `target` — so the floor is honored as a MINIMUM (never undershot) and the ceiling caps the spike.\n * Draws a readable foreground from a real palette (a tonal / lightness ramp) instead of a neutral gray,\n * so text keeps the theme's color while hitting its band. If nothing lands in band (ramp too coarse or\n * the surface can't support it), returns the color nearest the band edge.\n */\nexport function pickInBand(\n ramp: OklchColor[],\n surface: OklchColor | string,\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n): OklchColor {\n const scored = ramp.map((c) => ({\n c,\n lc: Math.abs(apcaContrast(c, surface)),\n }));\n const inBand = scored.filter((s) => s.lc >= band.floor && s.lc <= band.ceiling);\n const pool = inBand.length ? inBand : scored;\n const err = (lc: number) => (inBand.length ? Math.abs(lc - band.target) : Math.min(Math.abs(lc - band.floor), Math.abs(lc - band.ceiling)));\n return pool.reduce((best, s) => (err(s.lc) < err(best.lc) ? s : best), pool[0]).c;\n}\n\n/**\n * The effective glass surface color for the active theme + tint — the theme's light/dark floor\n * blended with the tint wash, mirroring the glass-* utilities (the wash sits at a FIXED lightness,\n * 72 light / 58 dark; only hue, chroma and alpha vary). Pair with pickForeground to choose readable\n * text on a tinted glass surface without reading pixels back.\n */\nexport function glassSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const washL = dark ? 58 : 72;\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: washL,\n c: tint.c * 2.5,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/**\n * The effective glass-SOLID surface color — the legible floor body text actually sits on (never sheer\n * glass). The solid floor (neutral `--glass-solid-l`, 99 light / 18 dark) is composited over the base\n * at `solidA` (the `--glass-solid-a` opacity, ~0.3–0.75), then the tint wash on top. Because that floor\n * is a KNOWN surface, banding text against this — rather than the sheer estimate — gives a real Lc.\n */\nexport function glassSolidSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n solidA: number,\n // Wash knobs — pass the LIVE --glass-wash-l / --glass-wash-c-mult when a theme overrides them\n // (moonstone night: 72 / 2), else the defaults mirror the CSS mode values exactly.\n washL: number = dark ? 58 : 72,\n washCMult = 2.5,\n // The SOLIDIFY floor — `glass` paints --glass-solidify (--glass-opaque-bg at --glass-opacity, default\n // 0.7) as its bottom background-IMAGE layer, i.e. above the background-color modeled by solidA and\n // below the wash. Omitting it models a surface far sheerer than what ships: on most themes the opaque\n // floor sits the same side of mid-grey as the page, so the error is a few Lc, but where the two\n // OPPOSE — moonstone night pairs a cream L84.9 floor with an L20 page — the model lands 27 L on the\n // wrong side and picks text of the wrong polarity entirely. Pass { l, c, a } to include it.\n solidify?: {\n l: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const solidL = dark ? 18 : 99;\n // The real paint order, composited in the real space — see compositeSurface for why lerping the\n // OKLCH coordinates instead is not the same operation.\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: solidL,\n c: 0,\n h: tint.h,\n a: solidA,\n },\n ...(solidify\n ? [\n {\n l: solidify.l,\n c: solidify.c,\n h: tint.h,\n a: solidify.a,\n },\n ]\n : []),\n {\n l: washL,\n c: tint.c * washCMult,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/** Options for {@link themeForeground}. */\nexport interface ThemeForegroundOptions {\n /** Which of the ramp generator's axes the text levels follow. */\n palette: \"tonal\" | \"lightness\" | \"hue\" | \"chroma\";\n /** Text level: 0 = the first swatch; each +1 is one step along the ramp. */\n level: number;\n /** Total steps (the ramp generator's count). */\n count: number;\n /** The chosen color the ramp is built from (the ramp generator's base). */\n base: {\n l: number;\n c: number;\n h: number;\n };\n /** Dark theme? Picks which lightness extreme the tonal/lightness ramps start from. */\n dark: boolean;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * Walk one of the ramp generator's ramps — built from the chosen `base` color — mapping ramp steps\n * to text levels. The ramp is **base-centered with `count` steps EITHER SIDE** (so it matches the\n * ramp generator's \"steps each side\"): level 0 = the readable extreme, level `count` = the base\n * (center), level `2·count` = the opposite extreme — `2·count + 1` levels total. The readable half\n * (levels 0..count) is unchanged, so the live --foreground / --muted-foreground cascade (which only\n * reads that half) is unaffected. `palette` picks the axis:\n * - tonal / lightness: level 0 is the readable lightness extreme (white in dark mode, black in\n * light), easing to the base's lightness at `count`, then on to the opposite extreme;\n * - hue: constant L + C — level 0 is base + count steps, ramping through the base and on the\n * other way (e.g. base `oklch(60% 0.15 255)`, count 8 → level 0 `oklch(60% 0.15 64.4)`);\n * - chroma: constant L + hue, chroma 0 → base → the gamut-displayable max.\n * Gamut-clamped.\n */\nexport function themeForeground(options: ThemeForegroundOptions): OklchColor {\n const { palette, level, count, base, dark, gamut = \"srgb\" } = options;\n // Piecewise around the centered base: readable extreme (level 0) → base (level count) → other.\n const span = (readable: number, mid: number, other: number) => {\n if (count <= 0) return mid;\n if (level <= count) return readable + (mid - readable) * (level / count);\n return mid + (other - mid) * Math.min(1, (level - count) / count);\n };\n const readableL = dark ? 100 : 0;\n const otherL = dark ? 0 : 100;\n const chromaCap = gamut === \"p3\" ? maxP3Chroma(base.l, base.h) : maxSrgbChroma(base.l, base.h);\n let color: OklchColor;\n switch (palette) {\n case \"hue\":\n color = {\n l: base.l,\n c: base.c,\n h: wrapHue(base.h + (count - level) * (360 / (2 * count + 1))),\n };\n break;\n case \"chroma\":\n color = {\n l: base.l,\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n break;\n case \"lightness\":\n color = {\n l: span(readableL, base.l, otherL),\n c: base.c,\n h: base.h,\n };\n break;\n default:\n color = {\n l: span(readableL, base.l, otherL),\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n }\n return clampToGamut(color, gamut);\n}\n\n// ── Readable foreground (soft contrast / ARC Bronze Simple Mode) ──────────────\n// pickForeground() maximizes contrast — it slams to the lightness extreme (pure white / near-black),\n// which reads as a glare/heaviness \"spike\". readableForeground() instead AIMS for a target APCA Lc\n// and stops: legible, but soft. The usage presets follow the ARC \"Bronze Simple Mode\" criterion\n// (readtech.org/ARC) — content-text thresholds, no font-lookup table: body Lc 75 (preferred 90),\n// other content Lc 60, large-fluent (>36px) Lc 45 with a MAX of Lc 90 to \"prevent excessive contrast\"\n// (so the ceiling is straight from the spec). floor = legible minimum, target = aim, ceiling = the cap.\n// `ui` / `non-text` / `disabled` are OUR extension — Bronze scopes to content text, excluding spot text.\n\n/** APCA-derived contrast bands per use case. floor = legible minimum, target = aim, ceiling = cap. */\nexport const READABLE_USAGE = {\n /** Fine / thin / small text — needs the most contrast; little soft room. */\n small: {\n floor: 90,\n target: 90,\n ceiling: 100,\n },\n /** Body text (~16px / 400) — ARC Bronze body: min 75, preferred 90. */\n body: {\n floor: 75,\n target: 80,\n ceiling: 90,\n },\n /** Large or fluent body (~18–24px) / semibold. */\n \"body-large\": {\n floor: 60,\n target: 72,\n ceiling: 88,\n },\n /** Headings / other content text — ARC Bronze \"other content\": min 60. */\n heading: {\n floor: 60,\n target: 66,\n ceiling: 82,\n },\n /** Large fluent text >36px (or ≥24px bold) — ARC Bronze: min 45, max 90. */\n large: {\n floor: 45,\n target: 58,\n ceiling: 76,\n },\n /** UI labels, icons, focus rings — beyond ARC Bronze scope (Bronze is content-text only). Target at the\n * APCA \"other content\" level (60); ceiling 70 stays below body's 75 floor, so icons read quieter than text. */\n ui: {\n floor: 45,\n target: 60,\n ceiling: 70,\n },\n /** Non-text: borders, dividers, graphics — beyond ARC Bronze scope. */\n \"non-text\": {\n floor: 30,\n target: 40,\n ceiling: 58,\n },\n /** Disabled / placeholder — beyond ARC Bronze scope (spot text). */\n disabled: {\n floor: 30,\n target: 33,\n ceiling: 46,\n },\n} as const;\n\nexport type ReadableUsage = keyof typeof READABLE_USAGE;\n\n/** Options for {@link readableForeground}. */\nexport interface ReadableForegroundOptions {\n /** Use-case preset that sets the {floor, target, ceiling} band (default \"body\"). */\n usage?: ReadableUsage;\n /** Aim for this |APCA Lc| — overrides the usage preset's target. */\n target?: number;\n /** Never exceed this |Lc| — the anti-spike cap; overrides the usage preset. */\n ceiling?: number;\n /** Legibility minimum; if even the extreme can't reach it you get max contrast (add a scrim). */\n floor?: number;\n /** Hue to keep for tinted-but-readable text. Defaults to the background's hue. */\n hue?: number;\n /** Chroma to keep, gamut-clamped at the chosen lightness. 0 = neutral gray. */\n chroma?: number;\n /** Opt-in chroma floor (capped at `chroma`). When the band pins the pick to the lightness EXTREME\n * (target/floor beyond the surface's reach), the gamut annihilates the requested chroma there and\n * tinted text reads pure black/white; this backs the aim off to the most contrast available at a\n * lightness that still HOLDS this much chroma — never below an ACHIEVABLE band floor. Reachable\n * bands are unaffected. 0 (default) = off. */\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * A foreground that AIMS for a target APCA contrast on `bg` instead of maximizing it — legible\n * without the harsh pure-black / pure-white spike. Picks the lightness direction with headroom and\n * binary-searches the NEAREST lightness whose |Lc| meets the target (capped at the ceiling); keeps an\n * optional hue/chroma for tinted-but-readable text. The soft-contrast companion to {@link pickForeground}.\n * If even the lightness extreme can't reach the floor (a mid-gray background), it returns the maximum\n * contrast available — the signal that the text needs a scrim / solid backing.\n */\nexport function readableForeground(bg: OklchColor | string, opts: ReadableForegroundOptions = {}): OklchColor {\n const band = READABLE_USAGE[opts.usage ?? \"body\"];\n const target = opts.target ?? band.target;\n const ceiling = opts.ceiling ?? band.ceiling;\n const floor = opts.floor ?? band.floor;\n const { chroma = 0, minChroma = 0, gamut = \"srgb\" } = opts;\n\n const b = toColor(bg);\n const h = opts.hue ?? b.h;\n const cAt = (l: number) => Math.min(chroma, gamut === \"p3\" ? maxP3Chroma(l, h) : maxSrgbChroma(l, h));\n const lcAt = (l: number) =>\n Math.abs(\n apcaContrast(\n {\n l,\n c: cAt(l),\n h,\n },\n b,\n ),\n );\n\n const extreme = lcAt(0) >= lcAt(100) ? 0 : 100; // the lightness direction with headroom\n const reach = lcAt(extreme); // the most contrast this background can give\n // Aim for the target, never past the ceiling; if we can't even clear the floor, take the max.\n let want = reach < floor ? reach : Math.min(target, ceiling, reach);\n\n // Chroma preservation (opt-in, minChroma > 0): want === reach means the band sits beyond what this\n // surface can give, so the search below would pin to the lightness EXTREME — where the gamut\n // annihilates the requested chroma and tinted text reads pure black/white (moonstone-dark cream: reach\n // ≈ 68 < body floor 75 → L≈0 → a chosen accent renders BLACK). Trade the few unreachable Lc points\n // for color: aim instead at the most contrast available at a lightness that still HOLDS the kept\n // chroma — never below the band floor when the floor is achievable. In-reach bands never get here.\n const keep = Math.min(minChroma, chroma);\n if (keep > 0 && want >= reach) {\n // Strict in-gamut test at the kept chroma (÷0.97 so the margined cAt still returns ≥ keep). The\n // shared eps-tolerant gamut test reports PHANTOM chroma at L≈0 — cubed near-zero channels sit\n // inside its 1e-4 tolerance (up to ~0.11 at red/pink hues) — which would converge this search\n // right back to the extreme; eps 1e-6 rejects it.\n const holdsKeep = (l: number) => {\n const [r, g, b3] = gamut === \"p3\" ? oklchToLinearP3(l, keep / 0.97, h) : oklchToLinearSrgb(l, keep / 0.97, h);\n const eps = 1e-6;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b3 >= -eps && b3 <= 1 + eps;\n };\n // Max chroma is ~unimodal in lightness and 0 at both extremes: holdsKeep is false at the extreme\n // and (guarded) true at bg.l, so bisect for the holding lightness NEAREST the extreme.\n if (holdsKeep(b.l)) {\n let kLo = extreme;\n let kHi = b.l;\n for (let i = 0; i < 24; i++) {\n const mid = (kLo + kHi) / 2;\n if (holdsKeep(mid)) kHi = mid;\n else kLo = mid;\n }\n want = Math.min(want, Math.max(lcAt(kHi), reach >= floor ? floor : 0));\n }\n }\n\n // |Lc| rises monotonically from bg.l → the extreme, so binary-search the nearest L meeting `want`.\n let lo = b.l;\n let hi = extreme;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (lcAt(mid) < want) lo = mid;\n else hi = mid;\n }\n return clampToGamut(\n {\n l: hi,\n c: cAt(hi),\n h,\n },\n gamut,\n );\n}\n", + "content": "/**\n * OKLCH color primitives — parse / format an oklch string and generate symmetric ramps\n * (hue or chroma) around an existing color. Dependency-free; shares the {l, c, h} shape with\n * `GradientColor` in gradient-utils.ts.\n */\n\nexport interface OklchColor {\n /** Lightness, 0–100 (%) */\n l: number;\n /** Chroma, 0–~0.37 */\n c: number;\n /** Hue, 0–360 (degrees) */\n h: number;\n /** Optional alpha, 0–1 */\n alpha?: number;\n}\n\n/**\n * Practical oklch chroma ceiling. Both sRGB and Display-P3 colors stay below ~0.37 (per Evil\n * Martians; confirmed by sweeping every L×hue with the gamut math below — sRGB max ≈ 0.321,\n * P3 max ≈ 0.363). Used as the binary-search bound and the chroma-ramp default range.\n */\nexport const MAX_CHROMA = 0.37;\n\n/** Wrap a hue into [0, 360). */\nexport function wrapHue(h: number): number {\n return ((h % 360) + 360) % 360;\n}\n\n/** Clamp chroma into [0, MAX_CHROMA]. */\nexport function clampChroma(c: number): number {\n return Math.max(0, Math.min(MAX_CHROMA, c));\n}\n\n/** A bare lightness ≤ 1 is the 0–1 form (×100); otherwise it's already 0–100. */\nfunction normalizeLightness(l: number): number {\n return l <= 1 ? l * 100 : l;\n}\n\n/**\n * Parse an `oklch()` string into components. Handles both lightness conventions used in this\n * repo — `oklch(72% 0.05 255)` and `oklch(0.72 0.05 255)` — plus an optional `/ alpha`.\n * Returns null if the string isn't a parseable oklch().\n */\nexport function parseOklch(input: string): OklchColor | null {\n const match = /^\\s*oklch\\(([^)]+)\\)\\s*$/i.exec(input);\n if (!match) return null;\n\n const [coords, alphaPart] = match[1].split(\"/\");\n const parts = coords.trim().split(/\\s+/);\n if (parts.length < 3) return null;\n\n const lRaw = parts[0];\n const l = lRaw.endsWith(\"%\") ? Number.parseFloat(lRaw) : normalizeLightness(Number.parseFloat(lRaw));\n const c = Number.parseFloat(parts[1]);\n const h = Number.parseFloat(parts[2]);\n if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h)) return null;\n\n const color: OklchColor = {\n l,\n c,\n h,\n };\n if (alphaPart !== undefined) {\n const a = Number.parseFloat(alphaPart.trim());\n if (Number.isFinite(a)) color.alpha = a;\n }\n return color;\n}\n\n/** Format components back into an `oklch()` string (lightness as %). */\nexport function formatOklch(color: OklchColor, alpha?: number): string {\n const a = alpha ?? color.alpha;\n const base = `${color.l.toFixed(1)}% ${color.c.toFixed(3)} ${color.h.toFixed(1)}`;\n return a === undefined ? `oklch(${base})` : `oklch(${base} / ${a})`;\n}\n\n/** Coerce a base argument (string | OklchColor) into components; throws on an unparseable string. */\nfunction toColor(base: OklchColor | string): OklchColor {\n if (typeof base !== \"string\") return base;\n const parsed = parseOklch(base);\n if (!parsed) throw new Error(`oklch-utils: could not parse \"${base}\"`);\n return parsed;\n}\n\n/** Per-side step count, clamped to a sane [3, 12] (rounded). */\nfunction clampCount(count: number): number {\n return Math.max(3, Math.min(12, Math.round(count)));\n}\n\n/**\n * A full-range value ramp: `count` steps each side of `seed`, reaching `min` on the left and\n * `max` on the right, with the seed held at the center index. Step sizes differ per side (the\n * seed is rarely the midpoint), so the whole [min, max] range is always covered — more steps\n * just sample it finer. Returns `2 * count + 1` values, min → seed → max.\n */\nfunction rangeRamp(seed: number, min: number, max: number, count: number): number[] {\n const leftStep = (seed - min) / count;\n const rightStep = (max - seed) / count;\n const out: number[] = [];\n for (let i = count; i >= 1; i--) out.push(seed - leftStep * i);\n out.push(seed);\n for (let i = 1; i <= count; i++) out.push(seed + rightStep * i);\n return out;\n}\n\n/**\n * Hue ramp covering the FULL wheel: `count` steps each side, the seed centered, hues spread evenly\n * around the wheel (step = 360 / (2·count + 1)) and wrapped into [0, 360). Lightness + chroma are\n * held; `count` clamped to [3, 12]. Unlike chroma/lightness, hue is cyclic — 0° ≡ 360° — so the ramp\n * is distributed cyclically instead of running to both endpoints, which keeps the two edge swatches\n * distinct. e.g. seed 120, count 4 → 320, 0, 40, 80, 120, 160, 200, 240, 280.\n */\nexport function hueRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n const n = clampCount(count);\n const seed = wrapHue(color.h);\n const step = 360 / (2 * n + 1);\n const out: OklchColor[] = [];\n for (let k = -n; k <= n; k++) {\n out.push({\n ...color,\n h: wrapHue(seed + k * step),\n });\n }\n return out;\n}\n\n/** Full-wheel hue ramp as CSS oklch strings (the seed sits at the center index). */\nexport function hueRamp(base: OklchColor | string, count: number): string[] {\n return hueRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Hue harmonies ─────────────────────────────────────────────────────────────\n// Rotate the base hue to derive complementary / harmonious colors (lightness + chroma held). oklch\n// hue is perceptually even, so a 180° \"complement\" is a balanced opposite — not the skewed HSL/RGB one.\n\n/** The perceptual complement: same L + C, hue rotated 180° (alpha preserved), gamut-clamped — the\n * 180° case of {@link harmony}. */\nexport function complement(base: OklchColor | string, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n return harmony(\n base,\n [\n 180,\n ],\n gamut,\n )[1];\n}\n\n/**\n * Hue-harmony set: the base plus a color at each rotation `angle` in degrees (same L + C),\n * gamut-clamped. Returns `[base, …rotations]`. Common sets — analogous `[-30, 30]`, triadic\n * `[120, 240]`, split-complement `[150, 210]`, tetradic `[90, 180, 270]`.\n */\nexport function harmony(base: OklchColor | string, angles: number[], gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n const c = toColor(base);\n return [\n clampToGamut(c, gamut),\n ...angles.map((a) =>\n clampToGamut(\n {\n ...c,\n h: wrapHue(c.h + a),\n },\n gamut,\n ),\n ),\n ];\n}\n\n/** Named color-wheel relationships — the JS mirror of the --hue-* tokens in app/globals.css. */\nexport type HarmonicName =\n | \"base\"\n | \"complement\"\n | \"analogous-1\"\n | \"analogous-2\"\n | \"split-1\"\n | \"split-2\"\n | \"triad-1\"\n | \"triad-2\"\n | \"tetrad-1\"\n | \"tetrad-2\"\n | \"tetrad-3\"\n | \"square-1\"\n | \"square-2\"\n | \"square-3\";\n\n/**\n * Hue offsets (degrees) for each color-wheel relationship. MUST stay in lockstep with the `--hue-*`\n * tokens in app/globals.css (`calc(var(--harmony-h) + N)`) so a JS-solved icon/foreground hue lands on\n * the SAME angle as the CSS harmonic swatches. Pair with `harmonicHue()` + `readableForeground()` to get\n * a contrast-solved (APCA/ARC) color at the harmonic angle.\n */\nexport const HARMONIC_OFFSETS: Record = {\n base: 0,\n complement: 180,\n \"analogous-1\": -30,\n \"analogous-2\": 30,\n \"split-1\": 150,\n \"split-2\": 210,\n \"triad-1\": 120,\n \"triad-2\": 240,\n \"tetrad-1\": 60,\n \"tetrad-2\": 180,\n \"tetrad-3\": 240,\n \"square-1\": 90,\n \"square-2\": 180,\n \"square-3\": 270,\n};\n\n/** All harmonic relationship names, in wheel order (for building pickers). */\nexport const HARMONIC_NAMES = Object.keys(HARMONIC_OFFSETS) as HarmonicName[];\n\n/**\n * Rotate an anchor hue by a named color-wheel relationship, wrapped to [0, 360). The anchor is normally\n * the harmony hue (--harmony-h: the content hue, or 0 for the hue-less selenite/moonstone themes). Feed the\n * result to `readableForeground({ hue })` to keep the contrast solve while landing on the harmonic angle.\n */\nexport function harmonicHue(anchorH: number, name: HarmonicName): number {\n return wrapHue(anchorH + HARMONIC_OFFSETS[name]);\n}\n\n/**\n * Chroma ramp covering [0, cap]: `count` steps each side, the seed centered (left → 0, right →\n * cap). Lightness + hue are held; `count` clamped to [3, 12]. `max` defaults to the largest chroma\n * actually DISPLAYABLE for this L+hue in sRGB (≈0.32, varies by L/hue) — so the ramp reaches the\n * visible edge instead of a flat MAX_CHROMA that just clamps on screen. Pass `maxP3Chroma(l, h)` on\n * P3-capable displays for a punchier top end, or any explicit number to sweep a custom range.\n */\nexport function chromaRampColors(base: OklchColor | string, count: number, max?: number): OklchColor[] {\n const color = toColor(base);\n const cap = max ?? maxSrgbChroma(color.l, color.h);\n const seed = Math.max(0, Math.min(cap, color.c));\n return rangeRamp(seed, 0, cap, clampCount(count)).map((c) => ({\n ...color,\n c,\n }));\n}\n\n/** Chroma ramp as CSS oklch strings (the seed sits at the center index). */\nexport function chromaRamp(base: OklchColor | string, count: number, max?: number): string[] {\n return chromaRampColors(base, count, max).map((color) => formatOklch(color));\n}\n\n/**\n * Lightness ramp covering the FULL range [0, 100]: `count` steps each side, the seed centered\n * (left → 0, right → 100). Chroma + hue are held; `count` clamped to [3, 12].\n */\nexport function lightnessRampColors(base: OklchColor | string, count: number): OklchColor[] {\n const color = toColor(base);\n return rangeRamp(color.l, 0, 100, clampCount(count)).map((l) => ({\n ...color,\n l,\n }));\n}\n\n/** Full-range lightness ramp as CSS oklch strings (the seed sits at the center index). */\nexport function lightnessRamp(base: OklchColor | string, count: number): string[] {\n return lightnessRampColors(base, count).map((color) => formatOklch(color));\n}\n\n// ── Gamut-aware tonal scale ───────────────────────────────────────────────────\n// A tonal scale (the Radix/Tailwind 1→N pattern) is NOT a chroma shift: lightness eases\n// light→dark while chroma rises with the scale but is capped by the sRGB gamut — so chroma is\n// near-zero in the light tints, peaks in the mid-dark \"brand\" steps, then tapers in the darkest\n// steps (because dark colors can't physically hold as much chroma). The gamut cap is what\n// produces that peak-then-fall curve; a flat chroma shift cannot.\n\n/** OKLCH (l 0–100) → linear sRGB channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearSrgb(\n l: number,\n c: number,\n hDeg: number,\n): [\n number,\n number,\n number,\n] {\n const L = l / 100;\n const hr = (hDeg * Math.PI) / 180;\n const a = c * Math.cos(hr);\n const b = c * Math.sin(hr);\n const l_ = L + 0.3963377774 * a + 0.2158037573 * b;\n const m_ = L - 0.1055613458 * a - 0.0638541728 * b;\n const s_ = L - 0.0894841775 * a - 1.291485548 * b;\n const ll = l_ * l_ * l_;\n const mm = m_ * m_ * m_;\n const ss = s_ * s_ * s_;\n return [\n 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss,\n -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss,\n -0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the sRGB gamut. */\nexport function inSrgbGamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearSrgb(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** OKLCH (l 0–100) → linear Display-P3 channels (values outside [0,1] mean out of gamut). */\nfunction oklchToLinearP3(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n // linear sRGB → linear Display-P3 (same color, wider basis) — valid even for out-of-sRGB values.\n const [sr, sg, sb] = oklchToLinearSrgb(l, c, h);\n return [\n 0.8224621 * sr + 0.177538 * sg,\n 0.0331942 * sr + 0.9668058 * sg,\n 0.0170608 * sr + 0.072374 * sg + 0.9105652 * sb,\n ];\n}\n\n/** Whether an oklch color (l 0–100) sits inside the Display-P3 gamut. */\nexport function inP3Gamut(l: number, c: number, h: number): boolean {\n const [r, g, b] = oklchToLinearP3(l, c, h);\n const eps = 1e-4;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b >= -eps && b <= 1 + eps;\n}\n\n/** Largest in-gamut chroma for (l, h) under the given gamut test, via binary search. */\nfunction maxChromaFor(l: number, h: number, test: (l: number, c: number, h: number) => boolean): number {\n let lo = 0;\n let hi = MAX_CHROMA;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (test(l, mid, h)) lo = mid;\n else hi = mid;\n }\n // Sit 3% inside the boundary. A color exactly at the gamut edge renders fine in Chrome (CSS Color 4\n // hue-preserving gamut mapping) but Safari per-channel-clamps it toward GREY — so edge-chroma\n // foregrounds/icons went grey in Safari while staying tinted in Chrome. This margin keeps every emitted\n // color clearly in-gamut, so both engines show the same tint.\n return lo * 0.97;\n}\n\n/** Largest chroma keeping (l, h) inside the sRGB gamut. */\nexport function maxSrgbChroma(l: number, h: number): number {\n return maxChromaFor(l, h, inSrgbGamut);\n}\n\n/** Largest chroma keeping (l, h) inside the Display-P3 gamut. */\nexport function maxP3Chroma(l: number, h: number): number {\n return maxChromaFor(l, h, inP3Gamut);\n}\n\n/** Reduce a color's chroma until it fits the given gamut (lightness + hue preserved). */\nexport function clampToGamut(color: OklchColor, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor {\n const max = gamut === \"p3\" ? maxP3Chroma(color.l, color.h) : maxSrgbChroma(color.l, color.h);\n return {\n ...color,\n c: Math.min(color.c, max),\n };\n}\n\nexport interface TonalScaleOptions {\n /** Fixed hue, 0–360. */\n hue: number;\n /** Number of steps. Default 12. */\n steps?: number;\n /** Lightness (0–1) at the lightest step. Default 0.98. */\n lightest?: number;\n /** Lightness (0–1) at the darkest step. Default 0.35. */\n darkest?: number;\n /** Peak chroma intent — the scale builds toward this (gamut permitting). Default 0.2. */\n chroma?: number;\n /** Easing exponent for the lightness descent (>1 stays light longer). Default 2.2. */\n lightEase?: number;\n /** Easing exponent for the chroma rise (>1 stays subtle longer). Default 1.1. */\n chromaRise?: number;\n /** Fraction of the in-gamut chroma ceiling to use, for headroom. Default 0.94. */\n gamutFraction?: number;\n /** Cap chroma to a gamut envelope (gives the natural peak + dark falloff). `\"p3\"` allows a\n * punchier peak on wide-gamut displays; `false` disables clamping. Default `\"srgb\"`. */\n gamut?: \"srgb\" | \"p3\" | false;\n}\n\n/**\n * Generate a tonal color scale — a single hue with lightness eased light→dark and chroma that\n * rises with the scale but is capped by the chosen gamut (sRGB by default; pass `gamut: \"p3\"` for a\n * punchier wide-gamut peak). Returns lightest → darkest as OklchColor[].\n * `tonalScaleColors({ hue: 252 })` gives a Radix/Tailwind-shaped 12-step blue scale.\n */\nexport function tonalScaleColors(options: TonalScaleOptions): OklchColor[] {\n const {\n hue,\n steps = 12,\n lightest = 0.98,\n darkest = 0.35,\n chroma = 0.2,\n lightEase = 2.2,\n chromaRise = 1.1,\n gamutFraction = 0.94,\n gamut = \"srgb\",\n } = options;\n\n const ceiling = gamut === \"p3\" ? maxP3Chroma : maxSrgbChroma;\n const n = Math.max(2, Math.round(steps));\n const out: OklchColor[] = [];\n for (let i = 0; i < n; i++) {\n const p = i / (n - 1);\n const l = (lightest - (lightest - darkest) * p ** lightEase) * 100;\n let c = chroma * p ** chromaRise;\n if (gamut) c = Math.min(c, ceiling(l, hue) * gamutFraction);\n out.push({\n l,\n c: clampChroma(c),\n h: hue,\n });\n }\n return out;\n}\n\n/** Which ramp drives a {@link rampGradient}. */\nexport type RampGradientAxis = \"hue\" | \"lightness\" | \"tonal\" | \"chroma\";\n\n/** Gradient painting shape — same color-stop list, different geometry. */\nexport type GradientShape = \"linear\" | \"radial\" | \"conic\";\n\n/** Geometry knobs for {@link wrapGradient}: `angle` (linear direction / conic `from` rotation), `position`\n * (radial + conic center, e.g. `\"50% 50%\"`, `\"top left\"`), and the radial `shape` / `size`. All optional;\n * the defaults reproduce a centered circle / 90° linear (so omitting them is a no-op). */\nexport interface GradientGeometry {\n angle?: number;\n position?: string;\n radialShape?: \"circle\" | \"ellipse\";\n radialSize?: \"closest-side\" | \"closest-corner\" | \"farthest-side\" | \"farthest-corner\";\n}\n\n/** Wrap a `` in the chosen gradient function (all interpolate `in oklch`). Linear uses the\n * angle as its direction; conic as the `from` rotation about `position`; radial paints a `shape`+`size`\n * centered at `position` (angle unused). */\nexport function wrapGradient(shape: GradientShape, stops: string, geom: GradientGeometry = {}): string {\n const { angle = 90, position = \"50% 50%\", radialShape = \"circle\", radialSize = \"farthest-corner\" } = geom;\n switch (shape) {\n case \"radial\":\n return `radial-gradient(${radialShape} ${radialSize} at ${position} in oklch, ${stops})`;\n case \"conic\":\n return `conic-gradient(from ${angle}deg at ${position} in oklch, ${stops})`;\n default:\n return `linear-gradient(${angle}deg in oklch, ${stops})`;\n }\n}\n\n/**\n * Build a CSS `linear-gradient` from one of the ramps, the seed centered as a slightly wider plateau\n * so the theme color anchors the middle; the rest sit in equal-width bands left → right. `count`\n * steps each side (clamped [3,8]). Interpolated `in oklch` for a perceptual blend.\n * e.g. rampGradient(\"tonal\", { l: 62, c: 0.15, h: 250 }, 5).\n */\n/**\n * THE one axis → colors mapping every backdrop shares (CSS gradient background, canvas background,\n * docs demos), seed-centered with `count` steps per side. Single-sourced so an axis can't mean\n * different math in different engines: `tonal` is the seed's own lightness ramp, gamut-clamped\n * (theme-true — the pure-CSS gradient's original definition), NOT the fixed-chroma tonal scale.\n */\nexport function rampAxisColors(axis: RampGradientAxis, seed: OklchColor, count: number, gamut: \"srgb\" | \"p3\" = \"srgb\"): OklchColor[] {\n switch (axis) {\n case \"hue\":\n return hueRampColors(seed, count);\n case \"lightness\":\n return lightnessRampColors(seed, count);\n case \"chroma\":\n return chromaRampColors(seed, count, gamut === \"p3\" ? maxP3Chroma(seed.l, seed.h) : maxSrgbChroma(seed.l, seed.h));\n default:\n return lightnessRampColors(seed, count).map((color) => clampToGamut(color, gamut));\n }\n}\n\n/**\n * The lightness range a full-bleed BACKDROP may use while text over it stays readable.\n *\n * The ramps below deliberately cover their whole range — that is what makes them useful as swatches.\n * As a wallpaper it is the opposite of what you want: a lightness ramp runs L 0 → 100, so a single\n * solved foreground is legible at one end of the viewport and invisible at the other. This returns\n * the sub-range on the far side of `fg` where |Lc| stays at or above `targetLc`, measured with the\n * same APCA implementation the rest of the theme uses.\n *\n * Default target is 60 — APCA's non-body level. Body copy sits on veiled glass ABOVE the backdrop and\n * clears 75 comfortably from there; 60 is for the headings that sit directly on it. Asking 75 of the\n * raw backdrop would squeeze light mode into L 87–100 and leave nothing to make a gradient out of.\n *\n * |Lc| is monotonic in backdrop lightness on either side of a foreground, so this binary-searches the\n * boundary rather than sweeping.\n */\nexport function readableLightnessBand(\n fg: OklchColor | string,\n seed: OklchColor,\n targetLc = 60,\n headroom = 7,\n): {\n lMin: number;\n lMax: number;\n} {\n const text = toColor(fg);\n const passes = (l: number) =>\n Math.abs(\n apcaContrast(text, {\n ...seed,\n l,\n }),\n ) >= targetLc;\n /* A dark foreground pushes the backdrop light, and vice versa. */\n const backdropGoesLight = text.l < 50;\n let lo = backdropGoesLight ? 0 : 100;\n let hi = backdropGoesLight ? 100 : 0;\n if (!passes(hi)) {\n /* Nothing on this side clears the target (a mid-lightness foreground). Give back the whole\n range rather than an empty one — banding is a readability aid, not a hard gate. */\n return {\n lMin: 0,\n lMax: 100,\n };\n }\n for (let i = 0; i < 12; i++) {\n const mid = (lo + hi) / 2;\n if (passes(mid)) hi = mid;\n else lo = mid;\n }\n /* Stop `headroom` short of the absolute extreme. That end is the HIGH-contrast one, so backing off\n it costs no readability — but L 0 and L 100 are the two lightnesses that hold no chroma at all, so\n running to them washes the tint out of one edge of the wallpaper. Never let it cross the boundary. */\n const edge = Math.ceil(hi);\n return backdropGoesLight\n ? {\n lMin: edge,\n lMax: Math.max(edge + 1, 100 - headroom),\n }\n : {\n lMin: Math.min(Math.floor(hi) - 1, headroom),\n lMax: Math.floor(hi),\n };\n}\n\n/** Remap a ramp's lightnesses into `band`, preserving the ramp's shape. A ramp whose lightness is\n * constant (the hue and chroma axes) is clamped into the band instead of stretched across it.\n * Exported so the canvas backdrop bands the same ramps the CSS gradient does. */\nexport function fitToBand(\n colors: OklchColor[],\n band: {\n lMin: number;\n lMax: number;\n },\n): OklchColor[] {\n const ls = colors.map((c) => c.l);\n const lo = Math.min(...ls);\n const hi = Math.max(...ls);\n const span = hi - lo;\n if (span < 0.001) {\n const l = Math.min(band.lMax, Math.max(band.lMin, lo));\n return colors.map((c) => ({\n ...c,\n l,\n }));\n }\n return colors.map((c) => ({\n ...c,\n l: band.lMin + ((c.l - lo) / span) * (band.lMax - band.lMin),\n }));\n}\n\n/* Frescoes are authored as fixed multi-hue stops at one lightness, so they skip the ramp entirely —\n which also means they skip its banding and its conic loop-close. Apply both here. */\nexport function bandedFrescoStops(\n colors: string[],\n band:\n | {\n lMin: number;\n lMax: number;\n }\n | undefined,\n shape: GradientShape,\n): string {\n let out = colors;\n if (band) {\n out = out.map((css) => {\n const parsed = parseOklch(css);\n if (!parsed) return css;\n return formatOklch({\n ...parsed,\n l: Math.min(band.lMax, Math.max(band.lMin, parsed.l)),\n });\n });\n }\n if (shape === \"conic\" && out.length > 1) {\n const loop = [\n ...out,\n out[0],\n ];\n return loop.map((css, i) => `${css} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`).join(\", \");\n }\n return out.join(\", \");\n}\n\nexport function rampGradient(\n axis: RampGradientAxis,\n seed: OklchColor,\n count: number,\n options: {\n gamut?: \"srgb\" | \"p3\";\n shape?: GradientShape;\n /** Constrain the ramp's lightness to a readable range — see {@link readableLightnessBand}. */\n band?: {\n lMin: number;\n lMax: number;\n };\n } & GradientGeometry = {},\n): string {\n const { gamut = \"srgb\", shape = \"linear\", band, angle, position, radialShape, radialSize } = options;\n const ramped = rampAxisColors(axis, seed, count, gamut);\n const colors = band ? fitToBand(ramped, band) : ramped;\n\n /* A conic gradient wraps: whatever sits at 360° butts straight into 0°. An open ramp therefore\n meets its own opposite end at the twelve-o'clock line and draws a hard seam right through the\n wallpaper — at full range that is white against black. Close the loop instead, and drop the\n center plateau, which reads as a lopsided wedge once the ramp is bent into a circle.\n hue — already cyclic, so repeating the first color at 360° spaces evenly and joins invisibly.\n other — mirror out and back, giving a symmetric sweep that also ends where it started. */\n if (shape === \"conic\") {\n const loop =\n axis === \"hue\"\n ? [\n ...colors,\n colors[0],\n ]\n : [\n ...colors,\n ...colors.slice(0, -1).reverse(),\n ];\n const stops = loop.map((color, i) => `${formatOklch(color)} ${((i / (loop.length - 1)) * 100).toFixed(1)}%`);\n return wrapGradient(shape, stops.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n }\n\n const mid = Math.floor(colors.length / 2);\n const plateau = 7; // half-width (%) of the centered theme-color band\n const leftEnd = 50 - plateau;\n const rightStart = 50 + plateau;\n const parts = colors.map((color, i) => {\n const css = formatOklch(color);\n if (i === mid) return `${css} ${leftEnd}%, ${css} ${rightStart}%`;\n const pos = i < mid ? (i / mid) * leftEnd : rightStart + ((i - mid) / mid) * (100 - rightStart);\n return `${css} ${pos.toFixed(1)}%`;\n });\n return wrapGradient(shape, parts.join(\", \"), {\n angle,\n position,\n radialShape,\n radialSize,\n });\n}\n\n// ── APCA contrast (APCA-W3 / ARC) ─────────────────────────────────────────────\n// Inlined port of the APCA-W3 reference (© Andrew Somers / Myndex Research, licensed to the W3/AGWG).\n// The APCA constants below are the LOCKED beta set (0.1.x) and are used UNMODIFIED. APCA/ARC measures\n// readability contrast — it is NOT a means to claim WCAG 2 conformance. Verified to match apca-w3's\n// APCAcontrast() — see scripts/apca-oracle.mjs. Lc is signed: positive = dark text on a light\n// background, negative = light text on a dark one; |Lc| is the perceptual level (~45 large/UI, ~60\n// other content, ~75 body, ~90 fine). Polarity-aware — which WCAG-2's ratio is not — so it fits dark\n// mode + tinted glass far better.\n\n/** OKLCH (l 0–100) → gamma-encoded sRGB channels in [0, 1] (out-of-gamut values are clipped). */\nexport function oklchToSrgb(\n l: number,\n c: number,\n h: number,\n): [\n number,\n number,\n number,\n] {\n const [lr, lg, lb] = oklchToLinearSrgb(l, c, h);\n const encode = (v: number) => {\n const x = Math.max(0, Math.min(1, v));\n return x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055;\n };\n return [\n encode(lr),\n encode(lg),\n encode(lb),\n ];\n}\n\n/** Gamma-encoded sRGB channels in [0, 1] → OKLCH (l 0–100). Exact inverse of {@link oklchToSrgb} for\n * in-gamut input, so a composite→decompose round trip is lossless. */\nexport function srgbToOklch([r, g, b]: [\n number,\n number,\n number,\n]): OklchColor {\n const decode = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n const [lr, lg, lb] = [\n decode(r),\n decode(g),\n decode(b),\n ];\n const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);\n const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);\n const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);\n const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;\n const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;\n const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;\n return {\n l: L * 100,\n c: Math.hypot(a, bb),\n h: wrapHue((Math.atan2(bb, a) * 180) / Math.PI),\n };\n}\n\n/**\n * Composite a stack of translucent OKLCH layers THE WAY THE BROWSER DOES — CSS source-over on\n * gamma-encoded sRGB channels — and report the result in OKLCH.\n *\n * The obvious shortcut is to lerp the OKLCH coordinates instead (`l1·(1−a) + l2·a`, same for chroma).\n * That is a different operation and it is measurably wrong: OKLCH lightness is a cube-root-ish\n * perceptual scale, sRGB is a ~2.2 gamma, and chroma is a POLAR coordinate, so lerping it mixes two\n * hues along a straight line through the chroma plane rather than around it. Measured against the\n * shipped presets, the shortcut overstated light-mode body contrast by up to 2.8 Lc on the veiled\n * surface — enough to hide four presets sitting under the ARC body floor — while on the crystal\n * surface the error CHANGES SIGN by preset (+0.95 carnelian, −2.66 aventurine). That sign flip is why\n * this is a compositing fix and not a fudge factor: no single correction term can be both.\n *\n * `layers` paint bottom-first; `base` is fully opaque. Each layer's alpha is its own (a layer at\n * alpha 1 simply replaces what is under it). Hue is carried through from the top-most layer that\n * actually contributed chroma, so a neutral stack keeps its nominal hue instead of collapsing to 0.\n */\nexport function compositeSurface(\n base: OklchColor,\n layers: Array<\n OklchColor & {\n a: number;\n }\n >,\n): OklchColor {\n let px = oklchToSrgb(base.l, base.c, base.h);\n let hue = base.h;\n for (const layer of layers) {\n const a = Math.min(Math.max(layer.a, 0), 1);\n if (a <= 0) continue;\n const src = oklchToSrgb(layer.l, layer.c, layer.h);\n px = [\n src[0] * a + px[0] * (1 - a),\n src[1] * a + px[1] * (1 - a),\n src[2] * a + px[2] * (1 - a),\n ];\n if (layer.c > 0) hue = layer.h;\n }\n const out = srgbToOklch(px);\n // Below this the hue angle is numerical noise off a near-neutral pixel — keep the stack's nominal\n // hue so callers that tint from the surface (readableForeground, the ramp base) stay on the theme.\n return out.c < 1e-4\n ? {\n ...out,\n h: hue,\n }\n : out;\n}\n\n/** APCA screen luminance (Ys) from gamma-encoded sRGB. */\nfunction apcaLuminance([r, g, b]: [\n number,\n number,\n number,\n]): number {\n return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;\n}\n\n/**\n * APCA lightness contrast (Lc) between a text color and a background color. Signed: positive = dark\n * text on a light bg, negative = light text on a dark bg; use |Lc| for the level (~60 headings,\n * ~75 body text). A dependency-free port of APCA-W3 0.1.9.\n */\nexport function apcaContrast(text: OklchColor | string, bg: OklchColor | string): number {\n const t = toColor(text);\n const b = toColor(bg);\n let txtY = apcaLuminance(oklchToSrgb(t.l, t.c, t.h));\n let bgY = apcaLuminance(oklchToSrgb(b.l, b.c, b.h));\n const blkThrs = 0.022;\n // biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA blkClmp tuning constant, not √2\n const blkClmp = 1.414;\n txtY = txtY > blkThrs ? txtY : txtY + (blkThrs - txtY) ** blkClmp;\n bgY = bgY > blkThrs ? bgY : bgY + (blkThrs - bgY) ** blkClmp;\n if (Math.abs(bgY - txtY) < 0.0005) return 0;\n let lc: number;\n if (bgY > txtY) {\n const sapc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;\n lc = sapc < 0.1 ? 0 : sapc - 0.027;\n } else {\n const sapc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;\n lc = sapc > -0.1 ? 0 : sapc + 0.027;\n }\n return lc * 100;\n}\n\nconst FG_LIGHT: OklchColor = {\n l: 100,\n c: 0,\n h: 0,\n};\nconst FG_DARK: OklchColor = {\n l: 15,\n c: 0,\n h: 0,\n};\n\n/**\n * Pick whichever foreground has the higher APCA contrast on `bg` — by default near-white vs the\n * near-black `--foreground`. The light, perceptual way to choose a readable text/icon color: cheap\n * enough to run on a theme/tint change (microseconds), no per-frame work, no canvas readback.\n */\nexport function pickForeground(bg: OklchColor | string, light: OklchColor = FG_LIGHT, dark: OklchColor = FG_DARK): OklchColor {\n return Math.abs(apcaContrast(light, bg)) >= Math.abs(apcaContrast(dark, bg)) ? light : dark;\n}\n\n/**\n * From `ramp`, prefer colors whose |APCA Lc| on `surface` falls within [floor, ceiling] and sit closest\n * to `target` — so the floor is honored as a MINIMUM (never undershot) and the ceiling caps the spike.\n * Draws a readable foreground from a real palette (a tonal / lightness ramp) instead of a neutral gray,\n * so text keeps the theme's color while hitting its band. If nothing lands in band (ramp too coarse or\n * the surface can't support it), returns the color nearest the band edge.\n */\nexport function pickInBand(\n ramp: OklchColor[],\n surface: OklchColor | string,\n band: {\n floor: number;\n target: number;\n ceiling: number;\n },\n): OklchColor {\n const scored = ramp.map((c) => ({\n c,\n lc: Math.abs(apcaContrast(c, surface)),\n }));\n const inBand = scored.filter((s) => s.lc >= band.floor && s.lc <= band.ceiling);\n const pool = inBand.length ? inBand : scored;\n const err = (lc: number) => (inBand.length ? Math.abs(lc - band.target) : Math.min(Math.abs(lc - band.floor), Math.abs(lc - band.ceiling)));\n return pool.reduce((best, s) => (err(s.lc) < err(best.lc) ? s : best), pool[0]).c;\n}\n\n/**\n * The effective glass surface color for the active theme + tint — the theme's light/dark floor\n * blended with the tint wash, mirroring the glass-* utilities (the wash sits at a FIXED lightness,\n * 72 light / 58 dark; only hue, chroma and alpha vary). Pair with pickForeground to choose readable\n * text on a tinted glass surface without reading pixels back.\n */\nexport function glassSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const washL = dark ? 58 : 72;\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: washL,\n c: tint.c * 2.5,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/**\n * The effective glass-SOLID surface color — the legible floor body text actually sits on (never sheer\n * glass). The solid floor (neutral `--glass-solid-l`, 99 light / 18 dark) is composited over the base\n * at `solidA` (the `--glass-solid-a` opacity, ~0.3–0.75), then the tint wash on top. Because that floor\n * is a KNOWN surface, banding text against this — rather than the sheer estimate — gives a real Lc.\n */\nexport function glassSolidSurface(\n dark: boolean,\n tint: {\n h: number;\n c: number;\n a: number;\n },\n solidA: number,\n // Wash knobs — pass the LIVE --glass-wash-l / --glass-wash-c-mult when a theme overrides them\n // (moonstone night: 72 / 2), else the defaults mirror the CSS mode values exactly.\n washL: number = dark ? 58 : 72,\n washCMult = 2.5,\n // The SOLIDIFY floor — `glass` paints --glass-solidify (--glass-opaque-bg at --glass-opacity, default\n // 0.7) as its bottom background-IMAGE layer, i.e. above the background-color modeled by solidA and\n // below the wash. Omitting it models a surface far sheerer than what ships: on most themes the opaque\n // floor sits the same side of mid-grey as the page, so the error is a few Lc, but where the two\n // OPPOSE — moonstone night pairs a cream L84.9 floor with an L20 page — the model lands 27 L on the\n // wrong side and picks text of the wrong polarity entirely. Pass { l, c, a } to include it.\n solidify?: {\n l: number;\n c: number;\n a: number;\n },\n): OklchColor {\n const baseL = dark ? 20 : 95;\n const solidL = dark ? 18 : 99;\n // The real paint order, composited in the real space — see compositeSurface for why lerping the\n // OKLCH coordinates instead is not the same operation.\n return compositeSurface(\n {\n l: baseL,\n c: 0,\n h: tint.h,\n },\n [\n {\n l: solidL,\n c: 0,\n h: tint.h,\n a: solidA,\n },\n ...(solidify\n ? [\n {\n l: solidify.l,\n c: solidify.c,\n h: tint.h,\n a: solidify.a,\n },\n ]\n : []),\n {\n l: washL,\n c: tint.c * washCMult,\n h: tint.h,\n a: tint.a,\n },\n ],\n );\n}\n\n/** Options for {@link themeForeground}. */\nexport interface ThemeForegroundOptions {\n /** Which of the ramp generator's axes the text levels follow. */\n palette: \"tonal\" | \"lightness\" | \"hue\" | \"chroma\";\n /** Text level: 0 = the first swatch; each +1 is one step along the ramp. */\n level: number;\n /** Total steps (the ramp generator's count). */\n count: number;\n /** The chosen color the ramp is built from (the ramp generator's base). */\n base: {\n l: number;\n c: number;\n h: number;\n };\n /** Dark theme? Picks which lightness extreme the tonal/lightness ramps start from. */\n dark: boolean;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * Walk one of the ramp generator's ramps — built from the chosen `base` color — mapping ramp steps\n * to text levels. The ramp is **base-centered with `count` steps EITHER SIDE** (so it matches the\n * ramp generator's \"steps each side\"): level 0 = the readable extreme, level `count` = the base\n * (center), level `2·count` = the opposite extreme — `2·count + 1` levels total. The readable half\n * (levels 0..count) is unchanged, so the live --foreground / --muted-foreground cascade (which only\n * reads that half) is unaffected. `palette` picks the axis:\n * - tonal / lightness: level 0 is the readable lightness extreme (white in dark mode, black in\n * light), easing to the base's lightness at `count`, then on to the opposite extreme;\n * - hue: constant L + C — level 0 is base + count steps, ramping through the base and on the\n * other way (e.g. base `oklch(60% 0.15 255)`, count 8 → level 0 `oklch(60% 0.15 64.4)`);\n * - chroma: constant L + hue, chroma 0 → base → the gamut-displayable max.\n * Gamut-clamped.\n */\nexport function themeForeground(options: ThemeForegroundOptions): OklchColor {\n const { palette, level, count, base, dark, gamut = \"srgb\" } = options;\n // Piecewise around the centered base: readable extreme (level 0) → base (level count) → other.\n const span = (readable: number, mid: number, other: number) => {\n if (count <= 0) return mid;\n if (level <= count) return readable + (mid - readable) * (level / count);\n return mid + (other - mid) * Math.min(1, (level - count) / count);\n };\n const readableL = dark ? 100 : 0;\n const otherL = dark ? 0 : 100;\n const chromaCap = gamut === \"p3\" ? maxP3Chroma(base.l, base.h) : maxSrgbChroma(base.l, base.h);\n let color: OklchColor;\n switch (palette) {\n case \"hue\":\n color = {\n l: base.l,\n c: base.c,\n h: wrapHue(base.h + (count - level) * (360 / (2 * count + 1))),\n };\n break;\n case \"chroma\":\n color = {\n l: base.l,\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n break;\n case \"lightness\":\n color = {\n l: span(readableL, base.l, otherL),\n c: base.c,\n h: base.h,\n };\n break;\n default:\n color = {\n l: span(readableL, base.l, otherL),\n c: span(0, base.c, chromaCap),\n h: base.h,\n };\n }\n return clampToGamut(color, gamut);\n}\n\n// ── Readable foreground (soft contrast / ARC Bronze Simple Mode) ──────────────\n// pickForeground() maximizes contrast — it slams to the lightness extreme (pure white / near-black),\n// which reads as a glare/heaviness \"spike\". readableForeground() instead AIMS for a target APCA Lc\n// and stops: legible, but soft. The usage presets follow the ARC \"Bronze Simple Mode\" criterion\n// (readtech.org/ARC) — content-text thresholds, no font-lookup table: body Lc 75 (preferred 90),\n// other content Lc 60, large-fluent (>36px) Lc 45 with a MAX of Lc 90 to \"prevent excessive contrast\"\n// (so the ceiling is straight from the spec). floor = legible minimum, target = aim, ceiling = the cap.\n// `ui` / `non-text` / `disabled` are OUR extension — Bronze scopes to content text, excluding spot text.\n\n/** APCA-derived contrast bands per use case. floor = legible minimum, target = aim, ceiling = cap. */\nexport const READABLE_USAGE = {\n /** Fine / thin / small text — needs the most contrast; little soft room. */\n small: {\n floor: 90,\n target: 90,\n ceiling: 100,\n },\n /** Body text (~16px / 400) — ARC Bronze body: min 75, preferred 90. */\n body: {\n floor: 75,\n target: 80,\n ceiling: 90,\n },\n /** Large or fluent body (~18–24px) / semibold. */\n \"body-large\": {\n floor: 60,\n target: 72,\n ceiling: 88,\n },\n /** Headings / other content text — ARC Bronze \"other content\": min 60. */\n heading: {\n floor: 60,\n target: 66,\n ceiling: 82,\n },\n /** Large fluent text >36px (or ≥24px bold) — ARC Bronze: min 45, max 90. */\n large: {\n floor: 45,\n target: 58,\n ceiling: 76,\n },\n /** UI labels, icons, focus rings — beyond ARC Bronze scope (Bronze is content-text only). Target at the\n * APCA \"other content\" level (60); ceiling 70 stays below body's 75 floor, so icons read quieter than text. */\n ui: {\n floor: 45,\n target: 60,\n ceiling: 70,\n },\n /** Non-text: borders, dividers, graphics — beyond ARC Bronze scope. */\n \"non-text\": {\n floor: 30,\n target: 40,\n ceiling: 58,\n },\n /** Disabled / placeholder — beyond ARC Bronze scope (spot text). */\n disabled: {\n floor: 30,\n target: 33,\n ceiling: 46,\n },\n} as const;\n\nexport type ReadableUsage = keyof typeof READABLE_USAGE;\n\n/** Options for {@link readableForeground}. */\nexport interface ReadableForegroundOptions {\n /** Use-case preset that sets the {floor, target, ceiling} band (default \"body\"). */\n usage?: ReadableUsage;\n /** Aim for this |APCA Lc| — overrides the usage preset's target. */\n target?: number;\n /** Never exceed this |Lc| — the anti-spike cap; overrides the usage preset. */\n ceiling?: number;\n /** Legibility minimum; if even the extreme can't reach it you get max contrast (add a scrim). */\n floor?: number;\n /** Hue to keep for tinted-but-readable text. Defaults to the background's hue. */\n hue?: number;\n /** Chroma to keep, gamut-clamped at the chosen lightness. 0 = neutral gray. */\n chroma?: number;\n /** Opt-in chroma floor (capped at `chroma`). When the band pins the pick to the lightness EXTREME\n * (target/floor beyond the surface's reach), the gamut annihilates the requested chroma there and\n * tinted text reads pure black/white; this backs the aim off to the most contrast available at a\n * lightness that still HOLDS this much chroma — never below an ACHIEVABLE band floor. Reachable\n * bands are unaffected. 0 (default) = off. */\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n}\n\n/**\n * A foreground that AIMS for a target APCA contrast on `bg` instead of maximizing it — legible\n * without the harsh pure-black / pure-white spike. Picks the lightness direction with headroom and\n * binary-searches the NEAREST lightness whose |Lc| meets the target (capped at the ceiling); keeps an\n * optional hue/chroma for tinted-but-readable text. The soft-contrast companion to {@link pickForeground}.\n * If even the lightness extreme can't reach the floor (a mid-gray background), it returns the maximum\n * contrast available — the signal that the text needs a scrim / solid backing.\n */\nexport function readableForeground(bg: OklchColor | string, opts: ReadableForegroundOptions = {}): OklchColor {\n const band = READABLE_USAGE[opts.usage ?? \"body\"];\n const target = opts.target ?? band.target;\n const ceiling = opts.ceiling ?? band.ceiling;\n const floor = opts.floor ?? band.floor;\n const { chroma = 0, minChroma = 0, gamut = \"srgb\" } = opts;\n\n const b = toColor(bg);\n const h = opts.hue ?? b.h;\n const cAt = (l: number) => Math.min(chroma, gamut === \"p3\" ? maxP3Chroma(l, h) : maxSrgbChroma(l, h));\n const lcAt = (l: number) =>\n Math.abs(\n apcaContrast(\n {\n l,\n c: cAt(l),\n h,\n },\n b,\n ),\n );\n\n const extreme = lcAt(0) >= lcAt(100) ? 0 : 100; // the lightness direction with headroom\n const reach = lcAt(extreme); // the most contrast this background can give\n // Aim for the target, never past the ceiling; if we can't even clear the floor, take the max.\n let want = reach < floor ? reach : Math.min(target, ceiling, reach);\n\n // Chroma preservation (opt-in, minChroma > 0): want === reach means the band sits beyond what this\n // surface can give, so the search below would pin to the lightness EXTREME — where the gamut\n // annihilates the requested chroma and tinted text reads pure black/white (moonstone-dark cream: reach\n // ≈ 68 < body floor 75 → L≈0 → a chosen accent renders BLACK). Trade the few unreachable Lc points\n // for color: aim instead at the most contrast available at a lightness that still HOLDS the kept\n // chroma — never below the band floor when the floor is achievable. In-reach bands never get here.\n const keep = Math.min(minChroma, chroma);\n if (keep > 0 && want >= reach) {\n // Strict in-gamut test at the kept chroma (÷0.97 so the margined cAt still returns ≥ keep). The\n // shared eps-tolerant gamut test reports PHANTOM chroma at L≈0 — cubed near-zero channels sit\n // inside its 1e-4 tolerance (up to ~0.11 at red/pink hues) — which would converge this search\n // right back to the extreme; eps 1e-6 rejects it.\n const holdsKeep = (l: number) => {\n const [r, g, b3] = gamut === \"p3\" ? oklchToLinearP3(l, keep / 0.97, h) : oklchToLinearSrgb(l, keep / 0.97, h);\n const eps = 1e-6;\n return r >= -eps && r <= 1 + eps && g >= -eps && g <= 1 + eps && b3 >= -eps && b3 <= 1 + eps;\n };\n // Max chroma is ~unimodal in lightness and 0 at both extremes: holdsKeep is false at the extreme\n // and (guarded) true at bg.l, so bisect for the holding lightness NEAREST the extreme.\n if (holdsKeep(b.l)) {\n let kLo = extreme;\n let kHi = b.l;\n for (let i = 0; i < 24; i++) {\n const mid = (kLo + kHi) / 2;\n if (holdsKeep(mid)) kHi = mid;\n else kLo = mid;\n }\n want = Math.min(want, Math.max(lcAt(kHi), reach >= floor ? floor : 0));\n }\n }\n\n // |Lc| rises monotonically from bg.l → the extreme, so binary-search the nearest L meeting `want`.\n let lo = b.l;\n let hi = extreme;\n for (let i = 0; i < 24; i++) {\n const mid = (lo + hi) / 2;\n if (lcAt(mid) < want) lo = mid;\n else hi = mid;\n }\n return clampToGamut(\n {\n l: hi,\n c: cAt(hi),\n h,\n },\n gamut,\n );\n}\n\n// ── Foreground solve ─────────────────────────────────────────────────────────\n// The model AutoForeground applies to every surface, lifted out of the component so the /colors tester\n// and scripts/check-contrast.mjs run the SAME solve instead of each re-deriving it. Every one of them\n// had drifted from the others, and none of the drifts was visible without measuring the emitted token:\n// the tester modelled no solidify floor (3.3 L off), skipped the tonal clip (so it marked the ramp's\n// pure-WHITE end point as the `fine` pick, a colour the component never emits), skipped the\n// show-through margin, seeded the ramp from --glass-tint-h rather than the composited surface hue, and\n// picked from the whole 2·count+1 ramp where the component only ever considers the readable half; the\n// guard measured readableForeground where the page ships a discrete ramp pick, which hid four presets\n// emitting #000000. Duplication was the root cause in all of them, so it is the thing being removed.\n\n/** A tier's contrast band: floor = legible minimum, target = aim, ceiling = anti-spike cap. */\nexport interface ContrastBand {\n floor: number;\n target: number;\n ceiling: number;\n}\n\n/** Lightness below which an sRGB colour renders as black whatever chroma is asked for — the ramp's dark\n * end is clipped here on tinted themes so a reach-limited tier keeps its hue instead of collapsing to\n * #000. 18 sits just above L15 (#130900 at the warm hue, still reading black at text size). */\nexport const TONAL_MIN_L = 18;\n/** The same clip at the WHITE end, where L100 is exactly achromatic. Deliberately much tighter than its\n * dark twin: near black a whole 18 points of lightness read as one colour and cost ~1.5 Lc to give up,\n * while near white a single 3-point step is worth 6–8 Lc. */\nexport const TONAL_MAX_L = 97;\n/** Uncertainty margin at a fully-sheer floor, decaying to 0 as the floor becomes exactly modelable. */\nexport const LC_MARGIN = 12;\n/** Baseline aim for a floor that IS modelled exactly, so certainty is not a reason to aim at the bare\n * minimum. Only the opaque set earns it; every other surface earns its margin from uncertainty. */\nexport const LC_AIM_KNOWN = 4;\n\n/** The backdrop's SURVIVING weight through a stack of alphas — every layer painted above attenuates it.\n * This is what the uncertainty margin scales: the more backdrop shows, the less the model can be\n * trusted. Pass each layer's alpha; order is irrelevant. */\nexport function showThrough(...alphas: number[]): number {\n return Math.min(\n Math.max(\n alphas.reduce((acc, a) => acc * (1 - Math.min(Math.max(a, 0), 1)), 1),\n 0,\n ),\n 1,\n );\n}\n\n/** Lift a band's TARGET by the uncertainty boost + the baseline aim. Floors and ceilings never move —\n * the margin aims higher, it never legalizes a harsher pick than the band allowed. */\nexport function boostBand(band: ContrastBand, lcBoost = 0, lcAim = 0): ContrastBand {\n return {\n ...band,\n target: Math.min(band.target + lcBoost + lcAim, band.ceiling),\n };\n}\n\nexport interface ForegroundRampOptions {\n palette: ThemeForegroundOptions[\"palette\"];\n count: number;\n base: OklchColor;\n dark: boolean;\n /** Levels to build. Defaults to the READABLE half (count + 1) — the only part a foreground is ever\n * drawn from. The full 2·count+1 ramp exists for display; picking from it would offer steps on the\n * far side of the base that the surface can never make legible. */\n levels?: number;\n}\n\n/** The ramp a foreground is picked from: the full build, plus the tonally-clipped subset. */\nexport interface ForegroundRamp {\n raw: OklchColor[];\n tonal: OklchColor[];\n}\n\n/**\n * Build the foreground ramp and its tonal clip.\n *\n * Both achromatic ends are dropped on TINTED themes: sRGB has almost no gamut volume there, so those\n * steps are pure black / pure white wearing a theme colour's name (at L0 any requested chroma renders\n * #000000; at L100, #ffffff). pickInBand reaches them whenever a band's target sits above every step —\n * it then picks for maximum contrast, which IS the extreme. NEUTRAL themes (base chroma 0) keep the\n * full ramp, since there black and white are the genuine ends of a grey scale rather than colours that\n * lost their hue.\n */\nexport function foregroundRamp(o: ForegroundRampOptions): ForegroundRamp {\n const levels = o.levels ?? o.count + 1;\n const raw = Array.from(\n {\n length: levels,\n },\n (_, level) =>\n themeForeground({\n palette: o.palette,\n level,\n count: o.count,\n base: o.base,\n dark: o.dark,\n }),\n );\n if (o.base.c <= 0)\n return {\n raw,\n tonal: raw,\n };\n const tonal = raw.filter((c) => c.l >= TONAL_MIN_L && c.l <= TONAL_MAX_L);\n return {\n raw,\n tonal: tonal.length ? tonal : raw,\n };\n}\n\n/**\n * Pick from the TONAL ramp, but never at the cost of the band's floor.\n *\n * The clip drops the steps that render black or white; if a band is so demanding that only those could\n * satisfy its FLOOR, legibility outranks hue and the full ramp comes back. Guard the FLOOR, not each\n * band's aspiration: the `small` band asks Lc 90, which no light surface reaches at all, so testing\n * against band.floor there would hand the extreme straight back on every light preset.\n */\nexport function pickTonalInBand(ramp: ForegroundRamp, surface: OklchColor | string, band: ContrastBand): OklchColor {\n const best = pickInBand(ramp.tonal, surface, band);\n if (ramp.tonal === ramp.raw) return best;\n const lc = (c: OklchColor) => Math.abs(apcaContrast(c, surface));\n if (lc(best) >= READABLE_USAGE.body.floor) return best;\n const full = pickInBand(ramp.raw, surface, band);\n return lc(full) > lc(best) ? full : best;\n}\n\n/**\n * The ADAPTIVE twin of {@link pickTonalInBand}, for surfaces the ramp cannot serve (they may need a\n * polarity it does not span — a LIGHT opaque card on a dark page needs DARK text).\n *\n * readableForeground aims at the band target and, when the target sits beyond what the surface can\n * give, returns the most contrast AVAILABLE — which is the lightness extreme, exactly where the gamut\n * annihilates the requested chroma. In light mode that is the default rather than an edge case: an L88\n * opaque floor tops out around 81–84 Lc while the opaque tiers aim at target + LC_AIM_KNOWN, so every\n * jewel's `--foreground-opaque` solved to pure black. Clipping to the same bounds costs 0.7–1.3 Lc and\n * returns a genuinely tinted ink.\n *\n * `minChroma` is NOT a substitute despite documenting this case: its search bisects toward the\n * BACKGROUND lightness, and a near-white floor cannot hold the kept chroma at any hue on the\n * dark-peaking arc, so it bails back to the extreme. It stays on for the accent path it was built for.\n */\nexport function readableTonal(\n surface: OklchColor,\n band: ContrastBand,\n opts: {\n hue: number;\n chroma: number;\n minChroma?: number;\n gamut?: \"srgb\" | \"p3\";\n },\n): OklchColor {\n const solved = readableForeground(surface, {\n floor: band.floor,\n target: band.target,\n ceiling: band.ceiling,\n hue: opts.hue,\n chroma: opts.chroma,\n minChroma: opts.minChroma ?? 0,\n gamut: opts.gamut,\n });\n // Neutral keeps the full range — black and white are the genuine ends of a grey scale there.\n if (opts.chroma <= 0) return solved;\n const l = Math.min(Math.max(solved.l, TONAL_MIN_L), TONAL_MAX_L);\n if (l === solved.l) return solved;\n const clipped = clampToGamut(\n {\n l,\n c: opts.chroma,\n h: opts.hue,\n },\n opts.gamut,\n );\n // Guard legibility, never the aspiration — the same line pickTonalInBand holds.\n return Math.abs(apcaContrast(clipped, surface)) >= READABLE_USAGE.body.floor ? clipped : solved;\n}\n", "type": "registry:lib", "target": "lib/oklch-utils.ts" } diff --git a/scripts/check-contrast.mjs b/scripts/check-contrast.mjs index 92c11ea..f3dcc5a 100644 --- a/scripts/check-contrast.mjs +++ b/scripts/check-contrast.mjs @@ -31,15 +31,25 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { apcaContrast, clampToGamut, glassSolidSurface, pickInBand, READABLE_USAGE, readableForeground, themeForeground } from "../lib/oklch-utils.ts"; - -/* Mirrors the ramp AutoForeground builds: the /colors default step count, and the clip that drops the - achromatic ends. Keep in step with components/auto-foreground.tsx. */ +import { + apcaContrast, + boostBand, + foregroundRamp, + glassSolidSurface, + LC_AIM_KNOWN, + LC_MARGIN, + pickTonalInBand, + READABLE_USAGE, + readableTonal, + showThrough, + TONAL_MAX_L, + TONAL_MIN_L, +} from "../lib/oklch-utils.ts"; + +/* The /colors ramp defaults AutoForeground solves at. The clip bounds, margins, ramp build and pick all + come from lib/oklch-utils now — this file used to re-declare them and had drifted. */ const RAMP_COUNT = 12; -const RAMP_CHROMA = 0.15; // the /colors ramp default AutoForeground solves adaptive tiers at -const LC_AIM_KNOWN = 4; // the opaque tier's baseline aim above target — mirrors auto-foreground.tsx -const TONAL_MIN_L = 18; -const TONAL_MAX_L = 97; +const RAMP_CHROMA = 0.15; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const read = (p) => readFileSync(join(ROOT, p), "utf8"); @@ -145,14 +155,9 @@ for (const p of all) { ramp's extreme, i.e. #000000. This guard reported all four green at 76.5 Lc throughout, because the continuous solve found a near-black it could reach. Modelling the ramp is what makes "the floor holds" mean "the emitted colour holds". */ - const base = { l: 60, c: 0.15, h: surface.h }; - const ramp = Array.from({ length: RAMP_COUNT + 1 }, (_, level) => - themeForeground({ palette: "lightness", level, count: RAMP_COUNT, base, dark }), - ).filter((s) => s.l >= TONAL_MIN_L && s.l <= TONAL_MAX_L); - // The show-through margin AutoForeground lifts each band target by (see LC_MARGIN there). - const boost = 12 * (1 - solidA) * (1 - a) * (1 - 0.7); - const band = { ...READABLE_USAGE.body, target: Math.min(READABLE_USAGE.body.target + boost, READABLE_USAGE.body.ceiling) }; - const fg = pickInBand(ramp, surface, band); + const ramp = foregroundRamp({ palette: "lightness", count: RAMP_COUNT, base: { l: 60, c: RAMP_CHROMA, h: surface.h }, dark }); + const band = boostBand(READABLE_USAGE.body, LC_MARGIN * showThrough(solidA, a, 0.7)); + const fg = pickTonalInBand(ramp, surface, band); const lc = Math.abs(apcaContrast(fg, surface)); report.push({ name: p.name, mode, lc, washL, a }); @@ -164,10 +169,8 @@ for (const p of all) { the theme's colour rather than having collapsed to black or white. Neutral scopes are exempt for the same reason the ramp clip exempts them: there the extremes are genuine ends of a grey scale. */ const opaqueSurface = { l: opaqueL, c: Math.min(c * cScale, cMax), h }; - const opaqueBand = { ...READABLE_USAGE.body, target: Math.min(READABLE_USAGE.body.target + LC_AIM_KNOWN, READABLE_USAGE.body.ceiling) }; - const opaqueSolved = readableForeground(opaqueSurface, { ...opaqueBand, hue: opaqueSurface.h, chroma: RAMP_CHROMA }); - const opaqueL2 = Math.min(Math.max(opaqueSolved.l, TONAL_MIN_L), TONAL_MAX_L); - const opaqueInk = opaqueL2 === opaqueSolved.l ? opaqueSolved : clampToGamut({ l: opaqueL2, c: RAMP_CHROMA, h: opaqueSurface.h }); + const opaqueBand = boostBand(READABLE_USAGE.body, 0, LC_AIM_KNOWN); + const opaqueInk = readableTonal(opaqueSurface, opaqueBand, { hue: opaqueSurface.h, chroma: RAMP_CHROMA }); const opaqueLc = Math.abs(apcaContrast(opaqueInk, opaqueSurface)); /* Assert LIGHTNESS, not chroma. At L≈0 the shared gamut test is eps-tolerant and reports PHANTOM chroma — #000000 comes back carrying c 0.067 at the warm hues — so a chroma assertion silently diff --git a/tests/auto-foreground-layer-caps.test.tsx b/tests/auto-foreground-layer-caps.test.tsx index 391d9db..8aa0495 100644 --- a/tests/auto-foreground-layer-caps.test.tsx +++ b/tests/auto-foreground-layer-caps.test.tsx @@ -24,7 +24,7 @@ import { render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AutoForeground } from "@/components/auto-foreground"; -import { apcaContrast, compositeSurface, type OklchColor, parseOklch } from "@/lib/oklch-utils"; +import { apcaContrast, compositeSurface, LC_MARGIN, type OklchColor, parseOklch } from "@/lib/oklch-utils"; const root = () => document.documentElement; const read = (name: string) => root().style.getPropertyValue(name).trim(); @@ -112,11 +112,11 @@ afterEach(() => { }); /* The band aim each adaptive tier solves for: the body target lifted by the show-through margin - (LC_MARGIN × the backdrop's surviving weight). Asserting the emitted ink hits THIS on the correctly- + (LC_MARGIN × the backdrop's surviving weight). LC_MARGIN is IMPORTED rather than re-stated — it is not + what these assert, so a change to it should move the expectation with it rather than fail here. Asserting the emitted ink hits THIS on the correctly- capped mirror is what makes the test discriminating — it is a statement that the component modelled the same surface this file does. A wrong cap gives a different surface, so the ink lands off its aim when measured here, with no magic constant to keep in step. */ -const LC_MARGIN = 12; const aim = (surviving: number) => Math.min(80 + LC_MARGIN * surviving, 90); const crystalAim = (opacity: number) => aim((1 - 0.3) * (1 - TINT_A) * (1 - 0.2) * (1 - opacity)); const chakraAim = (opacity: number) => aim((1 - 0.62) * (1 - TINT_A) * (1 - 0.2) * (1 - opacity)); From 3df4879a34ffcdbef957a2cce89f03314a9c659e Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:16:50 -0700 Subject: [PATCH 10/12] =?UTF-8?q?Issue=202=20is=20fixed=20and=20verified.?= =?UTF-8?q?=20Issue=201=20I=20could=20not=20reproduce=20=E2=80=94=20detail?= =?UTF-8?q?s=20below.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `styles.css` had an asymmetry. Two adjacent rules for the same page style, only one carving out `.glass-veil`: ```css [data-glass="opaque"] :where(.glass):not([data-material]):not(.glass-veil) { --srf-solidify: none; } [data-glass="opaque"] :where(.glass):not([data-material]) { --foreground: var(--foreground-opaque); … } ``` Crystal and chakra both exclude veiled. And the comment three lines above the second rule already gives the reason — *"a veiled floor is neutral"* — it just wasn't applied to the foreground remap. A veiled card doesn't paint `--glass-opaque-bg`; `glass-veil` moves the background-colour channel to the neutral `--veil-floor`. So those cards were getting ink solved for a surface they don't have. It showed as a **hue jump**, and the reason is the work from earlier in this session: the opaque floor is the one surface with nothing composited over it, so it sits at the raw `--glass-tint-h` (22.0) while every sheer surface lands a few degrees off (14.6). I split the rule so the **edge** treatment still applies to every adaptive panel — a veiled card under the opaque page should still read as a solid panel — while only the foreground remap is carved out. Verified live on both sides: ``` .glass (plain) → opaque tokens (22.0) CORRECT .glass.glass-veil → base tokens (14.6) CORRECT borders: identical on both ``` Your instinct that it "might point to a real issue elsewhere" was right — it's a page-style bug, not a `/colors` bug, and it affects any veiled card on an opaque page. What I did verify: **`opaque` is the only material that sets `--srf-filter: none`.** Every other one runs a live `backdrop-filter: blur(…) saturate(…)`, and the page's backdrop is a `position: fixed` layer with no compositing promotion. That matches your symptom exactly and is a well-known WebKit pairing. What I couldn't do is confirm it. Two attempts, both dead ends, and I want to be clear I initially misread one: - **Screenshots**: I claimed I'd caught it, then found the settled frame was byte-identical to the mid-scroll one (both 627.8 kB). That pink panel was just the goldstone tint I'd left set. A screenshot samples a settled frame, so it structurally cannot catch a per-frame artifact. - **Frame timing**: median 10 ms with blur on *and* off, zero frames over 20 ms. But `scrollTo` in rAF drives scrolling on the **main thread**, and backdrop-filter artifacts are specific to **compositor-driven** scrolling (trackpad/wheel momentum) — which this tooling can't emulate. So the test never exercised the failing path. The standard mitigation is promoting the fixed backdrop layer (`transform: translateZ(0)` or `will-change: transform`) so the compositor keeps it on its own layer and the blur samples it cheaply. It's one element per background component, low cost. But it's a rendering change I'd be shipping blind, and speculative compositing hints can trade one artifact for another. If you want, I can add it and you scroll-test — that's a 30-second confirmation for you and unverifiable for me. Otherwise a screen recording of the glitch would let me narrow it properly. ``` ✓ 175 tests · lint · theme (121 rules) · registry ``` --- app/theme/styles.css | 12 ++++++++++++ public/r/theme.json | 2 +- registry/theme/globals.css | 12 ++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/theme/styles.css b/app/theme/styles.css index 3c20956..ec01d98 100644 --- a/app/theme/styles.css +++ b/app/theme/styles.css @@ -106,6 +106,8 @@ veiled floor is neutral and the layer is real there. */ --srf-solidify: none; } +/* The uniform EDGE applies to every adaptive panel, veiled or not — a veiled card under the opaque page + still reads as a solid panel and needs the same outline. */ [data-glass="opaque"] :where(.glass):not([data-material]) { --srf-border-color: var(--glass-opaque-outline, var(--glass-border)); --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px)); @@ -114,6 +116,16 @@ some don't". Under the opaque page every solid panel takes the uniform edge; bordered elements resolve the same vars, so nothing double-draws. */ border: var(--srf-border-w) solid var(--srf-border-color); +} +/* The FOREGROUND remap is :not(.glass-veil) — the same carve-out crystal and chakra make, and the same + one the --srf-solidify rule above already makes for this page style. A veiled card does not paint + --glass-opaque-bg: glass-veil moves the background-COLOR channel to the neutral --veil-floor, so its + text sits on the veil surface the BASE tokens are banded for, not on the opaque floor. Remapping it + here handed those cards ink solved for a surface they do not have — visible as a hue jump, since the + opaque floor is the one surface with nothing composited over it and so sits at the raw --glass-tint-h + while every sheer surface lands a few degrees off it (measured on the /colors panel: 22.0 vs 14.6). + Splitting the rule is what keeps the edge treatment while dropping the foreground one. */ +[data-glass="opaque"] :where(.glass):not([data-material]):not(.glass-veil) { --foreground: var(--foreground-opaque); --foreground-soft: var(--foreground-soft-opaque); --foreground-strong: var(--foreground-strong-opaque); diff --git a/public/r/theme.json b/public/r/theme.json index 64b2c43..59554f1 100644 --- a/public/r/theme.json +++ b/public/r/theme.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/theme/globals.css", - "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) /
). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Solidify floor — the opaque backing UNDER sheer glass ───────────────────\n `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image\n layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on.\n It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE\n CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer\n (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the\n same surface; they only shared a token.\n Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right\n lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is\n not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes\n what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17\n scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with\n no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it\n pins both back to the opaque floor below and nothing changes there. */\n --glass-solidify-l: 92;\n /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather\n than 17 new per-preset numbers, derive from the ones each preset already declares: across the\n palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real\n ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot,\n turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their\n surface chroma; the dark-peaking arc keeps 80–86%. */\n --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65);\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode\n text contrast, and dark already clears the body floor on every preset with margin. Pinning to the\n opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only\n change: a lighter backing under dark glass would wash the whole mode out. */\n --glass-solidify-l: var(--glass-opaque-l);\n --glass-solidify-c-max: var(--glass-opaque-c-max);\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer\n material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque\n CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has\n the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */\n --glass-solidify-bg: oklch(\n calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%)\n min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now\n (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", + "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Solidify floor — the opaque backing UNDER sheer glass ───────────────────\n `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image\n layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on.\n It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE\n CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer\n (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the\n same surface; they only shared a token.\n Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right\n lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is\n not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes\n what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17\n scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with\n no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it\n pins both back to the opaque floor below and nothing changes there. */\n --glass-solidify-l: 92;\n /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather\n than 17 new per-preset numbers, derive from the ones each preset already declares: across the\n palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real\n ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot,\n turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their\n surface chroma; the dark-peaking arc keeps 80–86%. */\n --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65);\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode\n text contrast, and dark already clears the body floor on every preset with margin. Pinning to the\n opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only\n change: a lighter backing under dark glass would wash the whole mode out. */\n --glass-solidify-l: var(--glass-opaque-l);\n --glass-solidify-c-max: var(--glass-opaque-c-max);\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer\n material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque\n CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has\n the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */\n --glass-solidify-bg: oklch(\n calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%)\n min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now\n (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n/* The uniform EDGE applies to every adaptive panel, veiled or not — a veiled card under the opaque page\n still reads as a solid panel and needs the same outline. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n}\n/* The FOREGROUND remap is :not(.glass-veil) — the same carve-out crystal and chakra make, and the same\n one the --srf-solidify rule above already makes for this page style. A veiled card does not paint\n --glass-opaque-bg: glass-veil moves the background-COLOR channel to the neutral --veil-floor, so its\n text sits on the veil surface the BASE tokens are banded for, not on the opaque floor. Remapping it\n here handed those cards ink solved for a surface they do not have — visible as a hue jump, since the\n opaque floor is the one surface with nothing composited over it and so sits at the raw --glass-tint-h\n while every sheer surface lands a few degrees off it (measured on the /colors panel: 22.0 vs 14.6).\n Splitting the rule is what keeps the edge treatment while dropping the foreground one. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", "type": "registry:style", "target": "app/globals.css" } diff --git a/registry/theme/globals.css b/registry/theme/globals.css index d2fd40a..5895dc3 100644 --- a/registry/theme/globals.css +++ b/registry/theme/globals.css @@ -1648,6 +1648,8 @@ veiled floor is neutral and the layer is real there. */ --srf-solidify: none; } +/* The uniform EDGE applies to every adaptive panel, veiled or not — a veiled card under the opaque page + still reads as a solid panel and needs the same outline. */ [data-glass="opaque"] :where(.glass):not([data-material]) { --srf-border-color: var(--glass-opaque-outline, var(--glass-border)); --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px)); @@ -1656,6 +1658,16 @@ some don't". Under the opaque page every solid panel takes the uniform edge; bordered elements resolve the same vars, so nothing double-draws. */ border: var(--srf-border-w) solid var(--srf-border-color); +} +/* The FOREGROUND remap is :not(.glass-veil) — the same carve-out crystal and chakra make, and the same + one the --srf-solidify rule above already makes for this page style. A veiled card does not paint + --glass-opaque-bg: glass-veil moves the background-COLOR channel to the neutral --veil-floor, so its + text sits on the veil surface the BASE tokens are banded for, not on the opaque floor. Remapping it + here handed those cards ink solved for a surface they do not have — visible as a hue jump, since the + opaque floor is the one surface with nothing composited over it and so sits at the raw --glass-tint-h + while every sheer surface lands a few degrees off it (measured on the /colors panel: 22.0 vs 14.6). + Splitting the rule is what keeps the edge treatment while dropping the foreground one. */ +[data-glass="opaque"] :where(.glass):not([data-material]):not(.glass-veil) { --foreground: var(--foreground-opaque); --foreground-soft: var(--foreground-soft-opaque); --foreground-strong: var(--foreground-strong-opaque); From ca0442cf8890777129ad7ed29017b83df7f208ea Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:22:22 -0700 Subject: [PATCH 11/12] =?UTF-8?q?Chromium-only=20is=20the=20useful=20clue?= =?UTF-8?q?=20=E2=80=94=20it=20rules=20*out*=20the=20WebKit=20theory=20I?= =?UTF-8?q?=20was=20chasing=20and=20points=20at=20layer=20promotion,=20whi?= =?UTF-8?q?ch=20the=20two=20engines=20handle=20differently.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed backdrop is **not promoted**. Measured on the deployed page: ``` DIV.fixed.inset-0.-z-10 will-change: auto transform: none contain: none ``` It sits at `z-index: -10` beneath the entire document, and every sheer material has to re-sample it. `opaque` is the **only** material setting `--srf-filter: none` — and the only one that doesn't glitch. That correlation is exact. I also checked the ancestor chain for backdrop-root triggers (`filter`, `opacity < 1`, `mask`, `will-change`, `contain`, `isolation`, `transform`) — **none**, so the backdrop root is the document. That rules out the misresolved-backdrop-root class of bug and leaves layer promotion. Chromium and WebKit differ exactly here: WebKit tends to promote fixed layers aggressively, Chromium doesn't unless hinted. That's consistent with it reproducing in one and not the other. A documented `@utility glass-backdrop-layer { will-change: transform }` in `utilities.css`, applied to all **10** `fixed inset-0 -z-10` containers across the four background components. I checked the precondition first rather than assuming: promoting with `will-change`/`transform` makes an element a containing block for fixed descendants, which would silently reparent them. That backdrop has **zero** `position: fixed` descendants and no `background-attachment: fixed`, so it's safe — and I wrote that precondition into the comment so anyone adding children knows to re-check. Verified it actually lands (a Tailwind `@utility` that never emits would pass every check silently): ``` will-change=transform fixedDescendants=0 backdrop rect = 1305x664 at (0,0) ← layout unchanged ``` **I still haven't reproduced the glitch or confirmed the fix** — there's no Chromium on this machine (checked `/Applications`, `PATH`, and `node_modules/.bin`). This is an evidence-backed diagnosis, not a verified repair, and layer promotion can occasionally trade one artifact for another. It needs your eyes in Chrome. If it doesn't clear it, the next thing I'd look at is the animated pattern children — `pattern-background.tsx` puts `will-change: transform` on several elements *inside* the backdrop, and a backdrop-filter sampling a region containing many composited sublayers is its own Chromium failure mode. ``` ✓ 175 tests · lint · typecheck · theme (122 rules) · patterns · registry ``` Both fixes are uncommitted, alongside the `styles.css` opaque fix from before. --- app/theme/utilities.css | 17 +++++++++++++++++ components/background-provider.tsx | 2 +- components/canvas-background.tsx | 2 +- components/gradient-background.tsx | 2 +- components/pattern-background.tsx | 17 ++++++++++------- public/r/background-controller.json | 8 ++++---- public/r/canvas-background.json | 2 +- public/r/gradient-background.json | 2 +- public/r/theme.json | 2 +- registry/theme/globals.css | 17 +++++++++++++++++ 10 files changed, 54 insertions(+), 17 deletions(-) diff --git a/app/theme/utilities.css b/app/theme/utilities.css index 46520b0..7e86bf8 100644 --- a/app/theme/utilities.css +++ b/app/theme/utilities.css @@ -101,6 +101,23 @@ --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a)); } +/* THE PAGE BACKDROP LAYER — the fixed, full-viewport ground every sheer material blurs. Put this on the + `fixed inset-0 -z-10` container each background component renders. + It exists to force a compositing layer. `backdrop-filter` re-samples everything painted beneath it, + and this layer sits at z-index -10 under the entire document; unpromoted, the compositor has to + re-rasterise it against the scroll position every frame, which is where the scroll artifact on the + sheer materials comes from. `opaque` is the ONE material that sets --srf-filter: none — and the one + material that never showed the artifact, which is the correlation that points here. + Measured on the deployed page, this element resolved will-change:auto / transform:none / + contain:none — nothing was promoting it. Promoting is safe specifically because it has ZERO + position:fixed descendants and no background-attachment:fixed; a transform/will-change would + otherwise become their containing block and reparent them. Re-check that before adding children. + will-change (rather than a translateZ hack) because the hint is honest here: this layer is on screen + and being sampled for the whole session, so there is nothing to hint OFF later. */ +@utility glass-backdrop-layer { + will-change: transform; +} + /* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */ diff --git a/components/background-provider.tsx b/components/background-provider.tsx index 7adbf45..1e3b988 100644 --- a/components/background-provider.tsx +++ b/components/background-provider.tsx @@ -322,7 +322,7 @@ function renderBackground(background: BackgroundType, args: RenderArgs) { <>
+
diff --git a/public/r/background-controller.json b/public/r/background-controller.json index 08c90cf..034ccec 100644 --- a/public/r/background-controller.json +++ b/public/r/background-controller.json @@ -22,25 +22,25 @@ }, { "path": "components/pattern-background.tsx", - "content": "import type { CSSProperties } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora\n * drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n];\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur/--pat-play pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nconst TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body}`)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts.\nconst GHOST = svgMask(\n \"\",\n);\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n \n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n
\n
\n {PAC_WALLS.map((w) => (\n \n ))}\n {PAC_LANES.map((lane) => (\n \n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n {PAC_VLANES.map((lane) => (\n \n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n
\n
\n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n
\n
\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n
\n \n
\n \n \n
\n );\n }\n return (\n \n );\n}\n", + "content": "import type { CSSProperties } from \"react\";\n\n/**\n * Pure-CSS pattern backgrounds — tileable wallpapers built entirely from `background-image` layers\n * (multiple backgrounds + gradients) and SVG `mask-image` silhouettes, all colored off the live\n * OKLCH tint tokens so they recolor with the theme. No canvas, no rAF loop: each static style is a\n * single GPU paint; the animated scenes (synthwave / moonrise grid scroll, the clouds / dune / aurora\n * drifts, the Pac-Man chase) move on compositor-only transforms. Sibling of GradientBackground\n * (CSS gradient) and CanvasBackground (JS canvas).\n */\n\nexport type PatternStyle = \"dots\" | \"grid\" | \"mesh\" | \"starfield\" | \"synthwave\" | \"moonrise\" | \"clouds\" | \"dune\" | \"aurora\" | \"chase\";\n\nexport const PATTERN_STYLES: PatternStyle[] = [\n \"dots\",\n \"grid\",\n \"mesh\",\n \"starfield\",\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n];\n\n/** Patterns that animate (CSS keyframes) — gates the speed control and shares the --pat-dur/--pat-play pace. */\nexport const ANIMATED_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n \"chase\",\n]);\n\n/** Horizon scenes with a sun/moon disc that can be placed left / center / right. */\nexport const DISC_PATTERNS = new Set([\n \"synthwave\",\n \"moonrise\",\n \"clouds\",\n \"dune\",\n \"aurora\",\n]);\n\n/** Scenes with a blowing-sand control (still / breeze / storm). */\nexport const SAND_PATTERNS = new Set([\n \"dune\",\n]);\n\n/** Star-field density — how many of the star field's points render (a \"range of stars\"). */\nexport type PatternDensity = \"sparse\" | \"medium\" | \"dense\";\nexport const PATTERN_DENSITIES: PatternDensity[] = [\n \"sparse\",\n \"medium\",\n \"dense\",\n];\nconst STAR_COUNT: Record = {\n sparse: 16,\n medium: 33,\n dense: 50,\n};\n/** Chase pellet-cell scale per density — sparse = larger cells (fewer per screen), dense = smaller (more). */\nconst DENSITY_SCALE: Record = {\n sparse: 1.4,\n medium: 1,\n dense: 0.68,\n};\n/** Tile-size scale for the geometric tiles (dots / grid) — a much airier ladder: the old sparse (1.4)\n * is the new DENSE, with medium and sparse extrapolated outward on the same ~1.4× step. */\nconst TILE_SCALE: Record = {\n sparse: 2.8,\n medium: 2,\n dense: 1.4,\n};\n\n// SVG silhouettes as mask sources. White fill + transparent ground works whether the browser samples\n// the mask by alpha or luminance. Wrapped in url(...) after encoding so it's a valid CSS value.\n// `stretch: true` sets preserveAspectRatio='none' so a wide silhouette (the dune crests) fills the\n// element's 100% × 100% mask box instead of letterboxing to the viewBox aspect (the SVG default —\n// which renders a full-width mask as a centered strip with cliff edges).\nconst svgMask = (body: string, viewBox = \"0 0 100 100\", stretch = false) =>\n `url(\"data:image/svg+xml,${encodeURIComponent(`${body}`)}\")`;\n\n// The ghost silhouette — used by the chase scene's fleeing ghosts.\nconst GHOST = svgMask(\n \"\",\n);\n\n// Scattered \"points\" for the galaxy — a fixed set so it's deterministic (SSR-safe, no Math.random).\n// Kept as an array so the density control can render just a slice of them.\nconst STAR_GRADIENTS = [\n [\n 8,\n 12,\n ],\n [\n 22,\n 64,\n ],\n [\n 15,\n 88,\n ],\n [\n 35,\n 26,\n ],\n [\n 44,\n 72,\n ],\n [\n 52,\n 16,\n ],\n [\n 61,\n 52,\n ],\n [\n 68,\n 84,\n ],\n [\n 73,\n 34,\n ],\n [\n 82,\n 66,\n ],\n [\n 88,\n 20,\n ],\n [\n 92,\n 80,\n ],\n [\n 28,\n 44,\n ],\n [\n 57,\n 90,\n ],\n [\n 4,\n 40,\n ],\n [\n 12,\n 6,\n ],\n [\n 19,\n 30,\n ],\n [\n 26,\n 78,\n ],\n [\n 33,\n 54,\n ],\n [\n 40,\n 20,\n ],\n [\n 47,\n 46,\n ],\n [\n 54,\n 68,\n ],\n [\n 63,\n 12,\n ],\n [\n 66,\n 40,\n ],\n [\n 70,\n 60,\n ],\n [\n 77,\n 78,\n ],\n [\n 80,\n 8,\n ],\n [\n 84,\n 48,\n ],\n [\n 90,\n 60,\n ],\n [\n 95,\n 34,\n ],\n [\n 6,\n 70,\n ],\n [\n 48,\n 4,\n ],\n [\n 58,\n 30,\n ],\n [\n 76,\n 22,\n ],\n [\n 2,\n 22,\n ],\n [\n 16,\n 50,\n ],\n [\n 24,\n 14,\n ],\n [\n 38,\n 88,\n ],\n [\n 50,\n 40,\n ],\n [\n 56,\n 58,\n ],\n [\n 64,\n 74,\n ],\n [\n 72,\n 46,\n ],\n [\n 86,\n 38,\n ],\n [\n 94,\n 12,\n ],\n [\n 10,\n 82,\n ],\n [\n 30,\n 68,\n ],\n [\n 42,\n 34,\n ],\n [\n 60,\n 6,\n ],\n [\n 74,\n 90,\n ],\n [\n 96,\n 66,\n ],\n].map(([x, y], i) => {\n // a range of stars — occasional bright glints, most fine\n const r = i % 7 === 0 ? \"2px\" : i % 4 === 0 ? \"1.6px\" : i % 3 === 0 ? \"1.3px\" : \"1px\";\n /* --star-core is the fraction of the dot that stays SOLID before the falloff starts. Default 0%\n reproduces the original `#fff, transparent` exactly (white at 0, transparent at 100% — a pure\n linear falloff), so every scene that does not set it is untouched.\n It exists because \"full opacity\" was not the same as \"not faded\": a linear falloff averages only\n alpha 0.333 across the dot's area, so on a light sky the stars delivered ~+7 L rather than the\n +22 their peak suggests. On a near-black night sky that is invisible as a problem — any white\n reads — which is why it only ever showed up by day. */\n return `radial-gradient(${r} ${r} at ${x}% ${y}%, #fff 0 var(--star-core, 0%), transparent)`;\n});\n\n// Easter egg: the \"Muse\" — the Agent mascot's head (fedora + visored helmet) traced as a sparse star\n// constellation in every star field (starfield / synthwave / moonrise / clouds / dune / aurora). The dots are anchor points sampled\n// from the mascot SVG's bezier paths, cropped to the head (the coat sprawled into unreadable scatter),\n// thinned to ~45 (downsample, dedupe) and re-normalized; rendered as stars only — no fill, no lines — so the\n// fedora-and-helmet silhouette hides among the real stars until you catch it. Hue follows the accent-or-tint.\n// biome-ignore format: keep the sampled dots as one compact \"x,y x,y …\" string (not a 400-line array).\nconst MUSE_DOTS = \"67,66.5 65.1,52.1 66.7,42.3 75.8,38.4 11,48.8 82.9,45.3 92.3,42.7 100,36.8 84.4,32.8 85.2,58.8 75.5,65.3 71.9,58.3 51.9,42.9 26.4,48.3 60.9,59.3 38.5,42.3 0,50.4 18.3,38 19.3,28.2 19.9,14.7 25.7,5.9 37.5,2.8 51.1,0 62.9,0.5 72.8,5.6 77.5,15.3 79.7,25.7 96.2,90.9 67,78 43.4,85.6 87.4,72.2 87.4,82.7 16.9,65.6 48.2,59.2 37.5,62 28.1,64 19,87 27.2,88.7 88.4,92.4 38.6,70.3 51.5,81.1 21.5,73 33.6,81 53.7,65.1 30.7,40.5\".split(\" \").map((p) => p.split(\",\").map(Number));\nconst MUSE_STYLES = `\n.star-muse {\n position: absolute;\n left: 6%;\n top: 3%;\n width: 58vh;\n height: 53.6vh;\n pointer-events: none;\n --muse-h: var(--accent-h, var(--glass-tint-h));\n filter: drop-shadow(0 0 1px oklch(0.72 0.17 var(--muse-h) / 0.5));\n /* Rides the star gate: hidden with the stars in the Tron scenes' day mode (they set it to 0). In\n starfield the gate is 1 in BOTH modes now, so this sits at a flat 0.9 — the constellation is an\n easter egg and reads a touch under the field it hides in, rather than being mode-dependent. */\n opacity: calc(0.9 * var(--sw-stars-o, 1));\n}\n.star-muse circle {\n fill: oklch(0.96 0.04 var(--muse-h));\n}`;\n\nfunction MuseConstellation() {\n return (\n <>\n \n \n {MUSE_DOTS.map(([x, y], i) => (\n \n ))}\n \n \n );\n}\n\n// Shared sky furniture for the horizon scenes — the star layer (upper sky, fading toward the horizon,\n// day-hidden via the --sw-stars-o gate the palettes flip) and the palette-driven DISC. Every disc color is\n// a scene var (--sw-moon-1/2/3 core, --sw-maria L C H triple, --sw-disc-glow halo) set per mode by the\n// scene's [data-pattern] blocks, and --sw-disc-x carries the left/center/right placement control — so one\n// rule paints a rising violet moon, a desert silver moon, or a SUN with zero reskins. The sun is not a\n// second element: --sw-maria-o zeroes the craters (a sun has none) and --sw-disc-bloom adds the wide\n// second halo a bright source throws, which a moon does not. Both default to the moon's behaviour, so\n// moonrise and every night palette are untouched by their existence.\n// Placement knobs default to moonrise's horizon-dipping moon (40vh disc sitting ON the 42% horizon);\n// sky-borne scenes (clouds / dune) lift it with --moon-bottom and cancel the dip.\nconst SKY_STYLES = `\n.sw-stars {\n position: absolute;\n inset: 0 0 42% 0;\n background-repeat: no-repeat;\n opacity: var(--sw-stars-o, 1);\n -webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n mask-image: linear-gradient(to bottom, #000 50%, transparent 100%);\n}\n.scene-moon {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: var(--moon-bottom, 42%);\n width: var(--moon-size, 40vh);\n height: var(--moon-size, 40vh);\n transform: translate(-50%, var(--moon-dip, 30%));\n border-radius: 50%;\n background:\n radial-gradient(34% 30% at 63% 30%, oklch(var(--sw-maria) / calc(0.55 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(20% 18% at 38% 56%, oklch(var(--sw-maria) / calc(0.5 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(13% 12% at 56% 72%, oklch(var(--sw-maria) / calc(0.45 * var(--sw-maria-o, 1))) 0, transparent 62%),\n radial-gradient(circle at 42% 38%, var(--sw-moon-1) 0%, var(--sw-moon-2) 52%, var(--sw-moon-3) 100%);\n /* Two halos. The first is the moon's; the second is the sun's wide atmospheric bloom, a no-op\n (0 radius, transparent) unless a palette opts in. */\n filter:\n drop-shadow(0 0 var(--sw-disc-blur, 5vh) var(--sw-disc-glow))\n drop-shadow(0 0 var(--sw-disc-bloom, 0) var(--sw-disc-bloom-c, transparent));\n opacity: var(--sw-moon-o, 1);\n}\n/* The OUTRUN SUN — synthwave's disc, promoted to shared furniture so clouds / dune / aurora can put the\n same low banded sun on their day horizon. It sits ON the 42% line and dips below it; whatever the scene\n draws next (cloud deck, sand, water) covers the dipped half, so it reads as sitting in the horizon.\n Both discs render in every scene that wants either; --sw-sun-o / --sw-moon-o pick which one shows per\n mode, the same way --sw-stars-o gates the stars. Defaults are 1, so synthwave and moonrise — which\n choose their disc in JSX instead — are untouched. */\n.sw-sun {\n position: absolute;\n left: var(--sw-disc-x, 50%);\n bottom: 42%;\n width: var(--sun-size, 46vh);\n height: var(--sun-size, 46vh);\n transform: translate(-50%, 33%);\n border-radius: 50%;\n opacity: var(--sw-sun-o, 1);\n background: linear-gradient(\n to top,\n var(--sw-disc-1) 0%,\n var(--sw-disc-2) 24%,\n var(--sw-disc-3) 52%,\n var(--sw-disc-4) 78%,\n var(--sw-disc-5) 100%\n );\n -webkit-mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n mask-image:\n linear-gradient(to bottom, #000 0 42%, transparent 42%),\n repeating-linear-gradient(to bottom, #000 0 var(--sun-band, 0.85vh), transparent var(--sun-band, 0.85vh) calc(var(--sun-band, 0.85vh) * 2));\n filter: drop-shadow(0 0 5vh var(--sw-disc-glow));\n}`;\n\n// Synthwave / Tron horizon — two variants: `synthwave` (outrun sun) and `moonrise` (purple-blue moon),\n// each with a DAY and a NIGHT palette so the scene follows the light/dark toggle. Every scene color is a\n// --sw-* custom property: day values sit on [data-pattern=\"…\"], night values on `.dark [data-pattern=\"…\"]`\n// (the injected \n
\n
\n {PAC_WALLS.map((w) => (\n \n ))}\n {PAC_LANES.map((lane) => (\n \n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n {PAC_VLANES.map((lane) => (\n \n
\n
\n
\n
\n
\n
\n
\n
\n ))}\n
\n
\n );\n }\n if (style === \"synthwave\" || style === \"moonrise\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n const moon = style === \"moonrise\";\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"clouds\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"dune\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n // Dune faces light toward the disc: the diagonal face gradients read --du-light, so moving the\n // moon left/center/right relights every dune (and the ground sheet) to match.\n const duneLight = {\n left: \"160deg\",\n center: \"180deg\",\n right: \"200deg\",\n }[disc];\n // Sand level → veil opacity + speck multiplier. \"still\" zeroes BOTH (a calm desert has no airborne\n // sand at all); it is not the same as the speed control's \"static\", which only pauses motion.\n const [sandVeil, sandGrain] = {\n still: [\n \"0\",\n \"0\",\n ],\n breeze: [\n \"0.55\",\n \"1\",\n ],\n storm: [\n \"1\",\n \"1.8\",\n ],\n }[sand];\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n {/* Wind veils ride ABOVE the ridges and BELOW the haze — sand in the air, not on the ground. */}\n
\n
\n
\n
\n {/* Haze LAST-but-one: it must wash over the far ridges, not sit behind them. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"aurora\") {\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n \n
\n \n \n
\n
\n
\n
\n {/* AFTER the curtains: behind them, three layers of translucent light wash the disc green and\n it stops reading as a sun. Still before the glow and water, so the haze and waterline\n cover it the way they cover everything else at the horizon. */}\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n );\n }\n if (style === \"starfield\") {\n // Scene colors live in STARFIELD_STYLES' palette blocks (an inline background would beat the .dark\n // day/night flip); only the star GLINTS — white in both modes — are painted inline, on their own\n // layer so the palette's --sw-stars-o can dim them by day.\n const stars = STAR_GRADIENTS.slice(0, STAR_COUNT[density]).join(\", \");\n return (\n \n \n
\n \n \n
\n );\n }\n return (\n \n );\n}\n", "type": "registry:component", "target": "components/pattern-background.tsx" }, { "path": "components/canvas-background.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { backdropPalette, type CanvasRamp, type CanvasStyle, createCanvas, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport { readableLightnessBand } from \"@/lib/oklch-utils\";\n\ninterface CanvasBackgroundProps {\n /** Canvas style: gradient | lava | circle. */\n style?: CanvasStyle;\n /** Hue override (deg). When set, drives the canvas color instead of the live --glass-tint-h. */\n hue?: number;\n /** Ramp axis the colors follow (lightness = \"linear\"). */\n ramp?: CanvasRamp;\n /** Steps per side (4–12). */\n steps?: number;\n /** Gradient angle in degrees (gradient style). */\n angle?: number;\n /** Animation pace (0 = static). */\n speed?: number;\n /** Whether to animate. */\n animated?: boolean;\n /** Seed for deterministic placement. */\n seed?: string;\n className?: string;\n /** Opacity of the background (0–1). */\n opacity?: number;\n /** Blur the canvas. */\n blur?: boolean;\n}\n\nexport function CanvasBackground({\n style: canvasStyle = \"gradient\",\n hue,\n ramp = \"tonal\",\n steps = 6,\n angle = 90,\n speed = 1,\n animated = false,\n seed,\n className = \"\",\n opacity = 1,\n blur = false,\n}: CanvasBackgroundProps) {\n const canvasRef = React.useRef(null);\n const frameRef = React.useRef(undefined);\n const [dimensions, setDimensions] = React.useState({\n width: 1920,\n height: 1080,\n dpr: 1,\n });\n // Live base color tracks the glass tint, so the canvas recolors with the theme (shared with the\n // CSS gradient background).\n const tint = useBackdropTint();\n\n React.useEffect(() => {\n const update = () => {\n // Back the canvas at device resolution (capped 2×) so it isn't upscaled/blurry on HiDPI.\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n setDimensions({\n width: Math.round(window.innerWidth * dpr),\n height: Math.round(window.innerHeight * dpr),\n dpr,\n });\n };\n update();\n window.addEventListener(\"resize\", update);\n return () => window.removeEventListener(\"resize\", update);\n }, []);\n\n React.useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\", {\n colorSpace: \"display-p3\",\n });\n if (!ctx) return;\n canvas.width = dimensions.width;\n canvas.height = dimensions.height;\n\n // Shared with GradientBackground: theme-tracking base + fresco palette (`hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n const { step } = createCanvas({\n width: dimensions.width,\n height: dimensions.height,\n dpr: dimensions.dpr,\n color: base,\n colors: frescoColors,\n style: canvasStyle,\n ramp,\n steps,\n // Same readable band the CSS gradient backdrop uses, so switching backgrounds keeps text legible.\n band: tint.fg ? readableLightnessBand(tint.fg, base) : undefined,\n angle,\n speed,\n p3: tint.p3,\n seed: seed || window.location.pathname,\n });\n\n if (animated && speed > 0) {\n let last = 0;\n const paint = (now: number) => {\n frameRef.current = requestAnimationFrame(paint);\n // ~30fps cap — the gradient repaint / gooey blur are costly and the motion is slow.\n if (now - last < 33) return;\n last = now;\n step(ctx, now / 1000);\n };\n frameRef.current = requestAnimationFrame(paint);\n } else {\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n step(ctx, 0);\n }\n\n return () => {\n if (frameRef.current) cancelAnimationFrame(frameRef.current);\n };\n }, [\n dimensions,\n tint,\n hue,\n canvasStyle,\n ramp,\n steps,\n angle,\n speed,\n animated,\n seed,\n ]);\n\n return (\n \n );\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { backdropPalette, type CanvasRamp, type CanvasStyle, createCanvas, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport { readableLightnessBand } from \"@/lib/oklch-utils\";\n\ninterface CanvasBackgroundProps {\n /** Canvas style: gradient | lava | circle. */\n style?: CanvasStyle;\n /** Hue override (deg). When set, drives the canvas color instead of the live --glass-tint-h. */\n hue?: number;\n /** Ramp axis the colors follow (lightness = \"linear\"). */\n ramp?: CanvasRamp;\n /** Steps per side (4–12). */\n steps?: number;\n /** Gradient angle in degrees (gradient style). */\n angle?: number;\n /** Animation pace (0 = static). */\n speed?: number;\n /** Whether to animate. */\n animated?: boolean;\n /** Seed for deterministic placement. */\n seed?: string;\n className?: string;\n /** Opacity of the background (0–1). */\n opacity?: number;\n /** Blur the canvas. */\n blur?: boolean;\n}\n\nexport function CanvasBackground({\n style: canvasStyle = \"gradient\",\n hue,\n ramp = \"tonal\",\n steps = 6,\n angle = 90,\n speed = 1,\n animated = false,\n seed,\n className = \"\",\n opacity = 1,\n blur = false,\n}: CanvasBackgroundProps) {\n const canvasRef = React.useRef(null);\n const frameRef = React.useRef(undefined);\n const [dimensions, setDimensions] = React.useState({\n width: 1920,\n height: 1080,\n dpr: 1,\n });\n // Live base color tracks the glass tint, so the canvas recolors with the theme (shared with the\n // CSS gradient background).\n const tint = useBackdropTint();\n\n React.useEffect(() => {\n const update = () => {\n // Back the canvas at device resolution (capped 2×) so it isn't upscaled/blurry on HiDPI.\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n setDimensions({\n width: Math.round(window.innerWidth * dpr),\n height: Math.round(window.innerHeight * dpr),\n dpr,\n });\n };\n update();\n window.addEventListener(\"resize\", update);\n return () => window.removeEventListener(\"resize\", update);\n }, []);\n\n React.useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\", {\n colorSpace: \"display-p3\",\n });\n if (!ctx) return;\n canvas.width = dimensions.width;\n canvas.height = dimensions.height;\n\n // Shared with GradientBackground: theme-tracking base + fresco palette (`hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n const { step } = createCanvas({\n width: dimensions.width,\n height: dimensions.height,\n dpr: dimensions.dpr,\n color: base,\n colors: frescoColors,\n style: canvasStyle,\n ramp,\n steps,\n // Same readable band the CSS gradient backdrop uses, so switching backgrounds keeps text legible.\n band: tint.fg ? readableLightnessBand(tint.fg, base) : undefined,\n angle,\n speed,\n p3: tint.p3,\n seed: seed || window.location.pathname,\n });\n\n if (animated && speed > 0) {\n let last = 0;\n const paint = (now: number) => {\n frameRef.current = requestAnimationFrame(paint);\n // ~30fps cap — the gradient repaint / gooey blur are costly and the motion is slow.\n if (now - last < 33) return;\n last = now;\n step(ctx, now / 1000);\n };\n frameRef.current = requestAnimationFrame(paint);\n } else {\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n step(ctx, 0);\n }\n\n return () => {\n if (frameRef.current) cancelAnimationFrame(frameRef.current);\n };\n }, [\n dimensions,\n tint,\n hue,\n canvasStyle,\n ramp,\n steps,\n angle,\n speed,\n animated,\n seed,\n ]);\n\n return (\n \n );\n}\n", "type": "registry:component", "target": "components/canvas-background.tsx" }, { "path": "components/gradient-background.tsx", - "content": "\"use client\";\n\nimport { backdropPalette, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport {\n bandedFrescoStops,\n type GradientGeometry,\n type GradientShape,\n type RampGradientAxis,\n rampGradient,\n readableLightnessBand,\n wrapGradient,\n} from \"@/lib/oklch-utils\";\n\n/**\n * A ramp-driven gradient wallpaper. The gradient is one of our oklch ramps (hue / lightness / tonal\n * / chroma) centered on the live glass-tint color (so it recolors with the theme) with the center as a\n * slightly wider plateau, painted as a `linear`, `radial`, or `conic` gradient. Pure CSS — crisp at any DPI.\n */\nexport function GradientBackground({\n axis = \"tonal\",\n hue,\n angle = 90,\n shape = \"linear\",\n position,\n radialShape,\n radialSize,\n}: {\n axis?: RampGradientAxis;\n /** Hue override (deg). When set, drives the gradient color instead of the live --glass-tint-h. */\n hue?: number;\n angle?: number;\n shape?: GradientShape;\n} & GradientGeometry) {\n const tint = useBackdropTint();\n // Shared with CanvasBackground: theme-tracking base + fresco palette (the `hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n /* The ramps span their FULL range by design — as a wallpaper that put pure black at one edge of the\n viewport and pure white at the other, so a single solved foreground could only be readable at one\n end. Fit the ramp into the band where text over it clears APCA 60 instead. Falls back to the whole\n range if the foreground has not resolved yet (first paint, before AutoForeground runs). */\n const band = tint.fg ? readableLightnessBand(tint.fg, base) : undefined;\n const gradient = frescoColors\n ? wrapGradient(shape, bandedFrescoStops(frescoColors, band, shape), {\n angle,\n position,\n radialShape,\n radialSize,\n })\n : rampGradient(axis, base, 5, {\n angle,\n band,\n gamut: tint.p3 ? \"p3\" : \"srgb\",\n shape,\n position,\n radialShape,\n radialSize,\n });\n\n return (\n \n {/* Subtle organic highlight/shadow for depth — plain alpha layers, NO mix-blend-mode: a\n blend-mode element on the page can disable backdrop-filter RENDERING in Chromium, killing\n every glass blur above it (computed styles stay correct, paint silently fails). */}\n \n
\n );\n}\n", + "content": "\"use client\";\n\nimport { backdropPalette, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport {\n bandedFrescoStops,\n type GradientGeometry,\n type GradientShape,\n type RampGradientAxis,\n rampGradient,\n readableLightnessBand,\n wrapGradient,\n} from \"@/lib/oklch-utils\";\n\n/**\n * A ramp-driven gradient wallpaper. The gradient is one of our oklch ramps (hue / lightness / tonal\n * / chroma) centered on the live glass-tint color (so it recolors with the theme) with the center as a\n * slightly wider plateau, painted as a `linear`, `radial`, or `conic` gradient. Pure CSS — crisp at any DPI.\n */\nexport function GradientBackground({\n axis = \"tonal\",\n hue,\n angle = 90,\n shape = \"linear\",\n position,\n radialShape,\n radialSize,\n}: {\n axis?: RampGradientAxis;\n /** Hue override (deg). When set, drives the gradient color instead of the live --glass-tint-h. */\n hue?: number;\n angle?: number;\n shape?: GradientShape;\n} & GradientGeometry) {\n const tint = useBackdropTint();\n // Shared with CanvasBackground: theme-tracking base + fresco palette (the `hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n /* The ramps span their FULL range by design — as a wallpaper that put pure black at one edge of the\n viewport and pure white at the other, so a single solved foreground could only be readable at one\n end. Fit the ramp into the band where text over it clears APCA 60 instead. Falls back to the whole\n range if the foreground has not resolved yet (first paint, before AutoForeground runs). */\n const band = tint.fg ? readableLightnessBand(tint.fg, base) : undefined;\n const gradient = frescoColors\n ? wrapGradient(shape, bandedFrescoStops(frescoColors, band, shape), {\n angle,\n position,\n radialShape,\n radialSize,\n })\n : rampGradient(axis, base, 5, {\n angle,\n band,\n gamut: tint.p3 ? \"p3\" : \"srgb\",\n shape,\n position,\n radialShape,\n radialSize,\n });\n\n return (\n \n {/* Subtle organic highlight/shadow for depth — plain alpha layers, NO mix-blend-mode: a\n blend-mode element on the page can disable backdrop-filter RENDERING in Chromium, killing\n every glass blur above it (computed styles stay correct, paint silently fails). */}\n \n
\n );\n}\n", "type": "registry:component", "target": "components/gradient-background.tsx" }, { "path": "components/background-provider.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CanvasBackground } from \"@/components/canvas-background\";\nimport { GradientBackground } from \"@/components/gradient-background\";\nimport { PATTERN_DENSITIES, PATTERN_STYLES, PatternBackground, type PatternDensity, type PatternStyle } from \"@/components/pattern-background\";\nimport type { CanvasStyle } from \"@/lib/canvas-background-utils\";\nimport type { GradientGeometry, GradientShape, RampGradientAxis } from \"@/lib/oklch-utils\";\n\nexport type BackgroundType = \"gradient\" | \"canvas\" | \"pattern\" | \"none\";\n\nconst STORAGE_KEY = \"sistine-background\";\n\n/** Canvas styles (lava renamed from \"blobs\", circle from \"circles\"). */\nexport const CANVAS_STYLES: CanvasStyle[] = [\n \"gradient\",\n \"lava\",\n \"circle\",\n];\n\n/** Ramp axes shared by the Gradient background and the Canvas (lightness = the \"linear\" ramp). */\nexport const RAMP_AXES: RampGradientAxis[] = [\n \"tonal\",\n \"hue\",\n \"lightness\",\n \"chroma\",\n];\n\n/** Gradient painting shapes the Gradient background cycles through. */\nexport const GRADIENT_SHAPES: GradientShape[] = [\n \"linear\",\n \"radial\",\n \"conic\",\n];\n\n/** Center positions (radial + conic) the switcher cycles through. */\nexport const GRADIENT_POSITIONS: {\n value: string;\n label: string;\n}[] = [\n {\n value: \"50% 50%\",\n label: \"center\",\n },\n {\n value: \"0% 0%\",\n label: \"top-left\",\n },\n {\n value: \"100% 0%\",\n label: \"top-right\",\n },\n {\n value: \"100% 100%\",\n label: \"bottom-right\",\n },\n {\n value: \"0% 100%\",\n label: \"bottom-left\",\n },\n];\n\n/** The position values the cycler steps through (labels live in GRADIENT_POSITIONS). */\nconst GRADIENT_POSITION_VALUES = GRADIENT_POSITIONS.map((p) => p.value);\n\n/** Radial shape + size options. */\nexport const RADIAL_SHAPES: NonNullable[] = [\n \"circle\",\n \"ellipse\",\n];\nexport const RADIAL_SIZES: NonNullable[] = [\n \"farthest-corner\",\n \"farthest-side\",\n \"closest-corner\",\n \"closest-side\",\n];\n\n/** Angle presets — 45° steps around the circle, shared by the gradient + canvas angle cyclers. */\nconst ANGLES = [\n 0,\n 45,\n 90,\n 135,\n 180,\n 225,\n 270,\n 315,\n];\n\n/** Steps-per-side presets the canvas/ramps cycle through (4–12). */\nexport const CANVAS_STEPS = [\n 4,\n 6,\n 8,\n 10,\n 12,\n] as const;\n\n/** Animation pace presets. */\nexport const CANVAS_SPEEDS = [\n 0.5,\n 1,\n 2,\n] as const;\n\n/** Animated-pattern pace: loop/scroll duration in SECONDS (2s fastest … 16s slowest); 0 = static.\n * Shared by every animated pattern (synthwave, moonrise, clouds, dune, aurora, chase) via the --pat-dur CSS var. */\nexport const PATTERN_SPEEDS = [\n 2,\n 4,\n 8,\n 12,\n 16,\n 0,\n] as const;\n\n/** Sun/moon horizontal placement for the horizon scenes (synthwave / moonrise / clouds / dune) — off-center\n * clears the page's centered content. */\nexport const PATTERN_DISCS = [\n \"right\",\n \"center\",\n \"left\",\n] as const;\n\n/** Blowing-sand intensity for the dune scene — \"still\" is a calm desert (no airborne sand at all),\n * distinct from the speed control's \"static\", which only pauses the motion that is already there. */\nexport const PATTERN_SANDS = [\n \"breeze\",\n \"storm\",\n \"still\",\n] as const;\n\nfunction persistBackground(next: BackgroundType) {\n try {\n localStorage.setItem(STORAGE_KEY, next);\n } catch {\n // ignore storage failures (private mode, etc.)\n }\n}\n\nfunction pickRandom(items: readonly T[]): T {\n return items[Math.floor(Math.random() * items.length)];\n}\n\nfunction cycle(items: readonly T[], current: T): T {\n const i = items.indexOf(current);\n return items[(i + 1) % items.length];\n}\n\n/**\n * State that steps through a fixed preset list, wrapping at the end. Returns the value, a stable\n * advance-to-the-next-preset callback, and the raw setter.\n */\nfunction useCycle(\n items: readonly T[],\n initial: T,\n): [\n T,\n () => void,\n React.Dispatch>,\n] {\n const [value, setValue] = React.useState(initial);\n const next = React.useCallback(\n () => setValue((v) => cycle(items, v)),\n [\n items,\n ],\n );\n return [\n value,\n next,\n setValue,\n ];\n}\n\ninterface BackgroundContextValue {\n background: BackgroundType;\n setBackground: (background: BackgroundType) => void;\n /** The ramp axis driving the gradient background. */\n gradientAxis: RampGradientAxis;\n /** Switch to the gradient background and set its ramp axis. */\n setGradientAxis: (axis: RampGradientAxis) => void;\n /** Gradient angle in degrees (90 = left → right). */\n gradientAngle: number;\n /** Rotate the gradient by 45°. */\n cycleGradientAngle: () => void;\n /** The gradient painting shape (linear | radial | conic). */\n gradientShape: GradientShape;\n /** Advance to the next gradient shape. */\n cycleGradientShape: () => void;\n /** Center position for radial + conic gradients (CSS `at `). */\n gradientPosition: string;\n /** Advance to the next center-position preset. */\n cycleGradientPosition: () => void;\n /** Radial shape (circle | ellipse). */\n radialShape: NonNullable;\n /** Toggle the radial shape. */\n cycleRadialShape: () => void;\n /** Radial size keyword. */\n radialSize: NonNullable;\n /** Advance to the next radial size. */\n cycleRadialSize: () => void;\n // ── Canvas ──\n /** The canvas style (gradient | lava | circle). */\n canvasStyle: CanvasStyle;\n /** Switch to the canvas background and set its style. */\n setCanvasStyle: (style: CanvasStyle) => void;\n /** The ramp axis driving the canvas colors. */\n canvasRamp: RampGradientAxis;\n /** Switch to the canvas background and set its ramp axis. */\n setCanvasRamp: (axis: RampGradientAxis) => void;\n /** Steps per side (4–12). */\n canvasSteps: number;\n /** Advance to the next steps-per-side preset. */\n cycleCanvasSteps: () => void;\n /** Canvas gradient angle in degrees. */\n canvasAngle: number;\n /** Rotate the canvas gradient by 45°. */\n cycleCanvasAngle: () => void;\n /** Canvas animation pace. */\n canvasSpeed: number;\n /** Advance to the next animation-pace preset. */\n cycleCanvasSpeed: () => void;\n /** Whether the canvas is currently animating. */\n canvasAnimated: boolean;\n /** Toggle canvas animation on/off. */\n toggleCanvasAnimated: () => void;\n /** Switch to the canvas background and reshuffle it (new style + ramp + layout seed). */\n shuffleCanvas: () => void;\n // ── Pattern (pure-CSS wallpapers) ──\n /** The active CSS pattern style. */\n patternStyle: PatternStyle;\n /** Switch to the pattern background and set its style. */\n setPatternStyle: (style: PatternStyle) => void;\n /** Advance to the next pattern style. */\n cyclePatternStyle: () => void;\n /** Star-field density (sparse | medium | dense). */\n patternDensity: PatternDensity;\n /** Advance to the next star-field density. */\n cyclePatternDensity: () => void;\n /** Horizon-grid scroll pace (synthwave / moonrise); 0 is static. */\n patternSpeed: number;\n /** Advance to the next grid scroll pace (… → static). */\n cyclePatternSpeed: () => void;\n /** Sun/moon placement for the horizon scenes (right | center | left). */\n patternDisc: (typeof PATTERN_DISCS)[number];\n /** Advance to the next sun/moon placement. */\n cyclePatternDisc: () => void;\n /** Blowing-sand intensity for the dune scene (breeze | storm | still). */\n patternSand: (typeof PATTERN_SANDS)[number];\n /** Advance to the next blowing-sand level. */\n cyclePatternSand: () => void;\n // ── Base color (the \"none\" backdrop) ──\n /** Base color for the \"none\" backdrop; null = follow the theme (tint-tinted, light/dark aware). */\n baseColor: string | null;\n /** Override the \"none\" backdrop color; null clears back to the themed default. */\n setBaseColor: (color: string | null) => void;\n}\n\nconst BackgroundContext = React.createContext(null);\n\nexport function useBackground() {\n const context = React.useContext(BackgroundContext);\n if (!context) {\n throw new Error(\"useBackground must be used within a BackgroundProvider\");\n }\n return context;\n}\n\ninterface RenderArgs {\n gradientAxis: RampGradientAxis;\n gradientAngle: number;\n gradientShape: GradientShape;\n gradientPosition: string;\n radialShape: NonNullable;\n radialSize: NonNullable;\n canvasStyle: CanvasStyle;\n canvasRamp: RampGradientAxis;\n canvasSteps: number;\n canvasAngle: number;\n canvasSpeed: number;\n canvasAnimated: boolean;\n canvasSeed: string;\n patternStyle: PatternStyle;\n patternDensity: PatternDensity;\n patternSpeed: number;\n patternDisc: (typeof PATTERN_DISCS)[number];\n patternSand: (typeof PATTERN_SANDS)[number];\n baseColor: string | null;\n}\n\nfunction renderBackground(background: BackgroundType, args: RenderArgs) {\n switch (background) {\n case \"canvas\":\n // key forces a clean redraw when the style/ramp/steps/seed/animation change\n return (\n \n );\n case \"pattern\":\n return (\n \n );\n case \"none\":\n // Forgo the decorative backdrop — a clean solid that honors the theme (tint hue, light/dark) by\n // default, overridable by the user's chosen base color.\n return (\n <>\n \n \n \n );\n default:\n return (\n \n );\n }\n}\n\n/**\n * Holds the site-wide background choice (persisted to localStorage) and renders it\n * behind the app. Pair with to preview + tune each style.\n */\nexport function BackgroundProvider({ children }: { children: React.ReactNode }) {\n // SSR + first client render use \"gradient\" so hydration matches; localStorage is read after mount.\n const [background, setBackgroundState] = React.useState(\"pattern\");\n const [gradientAxis, setGradientAxisState] = React.useState(\"tonal\");\n const [gradientAngle, cycleGradientAngle] = useCycle(ANGLES, 90);\n const [gradientShape, cycleGradientShape] = useCycle(GRADIENT_SHAPES, \"linear\");\n const [gradientPosition, cycleGradientPosition] = useCycle(GRADIENT_POSITION_VALUES, \"50% 50%\");\n const [radialShape, cycleRadialShape] = useCycle(RADIAL_SHAPES, \"circle\");\n const [radialSize, cycleRadialSize] = useCycle(RADIAL_SIZES, \"farthest-corner\");\n const [canvasStyle, setCanvasStyleState] = React.useState(\"gradient\");\n const [canvasRamp, setCanvasRampState] = React.useState(\"tonal\");\n const [canvasSteps, cycleCanvasSteps] = useCycle(CANVAS_STEPS, 6);\n const [canvasAngle, cycleCanvasAngle] = useCycle(ANGLES, 90);\n const [canvasSpeed, cycleCanvasSpeed] = useCycle(CANVAS_SPEEDS, 1);\n const [canvasAnimated, setCanvasAnimated] = React.useState(false);\n const [canvasSeed, setCanvasSeed] = React.useState(\"sistine\");\n const [patternStyle, cyclePatternStyleState, setPatternStyleState] = useCycle(PATTERN_STYLES, \"moonrise\");\n const [patternDensity, cyclePatternDensityState] = useCycle(PATTERN_DENSITIES, \"medium\");\n /* Static by DEFAULT (0 = no animation, the last entry in PATTERN_SPEEDS). The pattern layer is a\n backdrop for reading glass against, and a moving one competes with the content for attention on\n first load — motion should be something you opt into, not something you have to switch off. The\n speed control still cycles 2/4/8/12/16/0, so the animation is one click away. */\n const [patternSpeed, cyclePatternSpeedState] = useCycle(PATTERN_SPEEDS, 0);\n const [baseColor, setBaseColor] = React.useState(null);\n const [patternDisc, cyclePatternDiscState] = useCycle(PATTERN_DISCS, \"right\");\n const [patternSand, cyclePatternSandState] = useCycle(PATTERN_SANDS, \"breeze\");\n\n React.useEffect(() => {\n const stored = localStorage.getItem(STORAGE_KEY);\n if (stored === \"gradient\" || stored === \"canvas\" || stored === \"pattern\" || stored === \"none\") {\n setBackgroundState(stored);\n }\n }, []);\n\n /** The coupling every per-style setter/cycler shares: switch to that background type, apply, persist. */\n const withType =\n (type: BackgroundType, fn: (...args: A) => void) =>\n (...args: A) => {\n setBackgroundState(type);\n fn(...args);\n persistBackground(type);\n };\n\n const value: BackgroundContextValue = {\n background,\n setBackground: (next) => {\n setBackgroundState(next);\n persistBackground(next);\n },\n gradientAxis,\n setGradientAxis: withType(\"gradient\", setGradientAxisState),\n gradientAngle,\n cycleGradientAngle,\n gradientShape,\n cycleGradientShape,\n gradientPosition,\n cycleGradientPosition,\n radialShape,\n cycleRadialShape,\n radialSize,\n cycleRadialSize,\n canvasStyle,\n setCanvasStyle: withType(\"canvas\", setCanvasStyleState),\n canvasRamp,\n setCanvasRamp: withType(\"canvas\", setCanvasRampState),\n canvasSteps,\n cycleCanvasSteps,\n canvasAngle,\n cycleCanvasAngle,\n canvasSpeed,\n cycleCanvasSpeed,\n canvasAnimated,\n toggleCanvasAnimated: () => setCanvasAnimated((on) => !on),\n shuffleCanvas: withType(\"canvas\", () => {\n setCanvasStyleState(pickRandom(CANVAS_STYLES));\n setCanvasRampState(pickRandom(RAMP_AXES));\n setCanvasSeed(Math.random().toString(36).slice(2));\n }),\n patternStyle,\n setPatternStyle: withType(\"pattern\", setPatternStyleState),\n cyclePatternStyle: withType(\"pattern\", cyclePatternStyleState),\n patternDensity,\n cyclePatternDensity: withType(\"pattern\", cyclePatternDensityState),\n patternSpeed,\n cyclePatternSpeed: withType(\"pattern\", cyclePatternSpeedState),\n patternDisc,\n cyclePatternDisc: withType(\"pattern\", cyclePatternDiscState),\n patternSand,\n cyclePatternSand: withType(\"pattern\", cyclePatternSandState),\n baseColor,\n setBaseColor,\n };\n\n return (\n \n {renderBackground(background, {\n gradientAxis,\n gradientAngle,\n gradientShape,\n gradientPosition,\n radialShape,\n radialSize,\n canvasStyle,\n canvasRamp,\n canvasSteps,\n canvasAngle,\n canvasSpeed,\n canvasAnimated,\n canvasSeed,\n patternStyle,\n patternDensity,\n patternSpeed,\n patternDisc,\n patternSand,\n baseColor,\n })}\n {children}\n \n );\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { CanvasBackground } from \"@/components/canvas-background\";\nimport { GradientBackground } from \"@/components/gradient-background\";\nimport { PATTERN_DENSITIES, PATTERN_STYLES, PatternBackground, type PatternDensity, type PatternStyle } from \"@/components/pattern-background\";\nimport type { CanvasStyle } from \"@/lib/canvas-background-utils\";\nimport type { GradientGeometry, GradientShape, RampGradientAxis } from \"@/lib/oklch-utils\";\n\nexport type BackgroundType = \"gradient\" | \"canvas\" | \"pattern\" | \"none\";\n\nconst STORAGE_KEY = \"sistine-background\";\n\n/** Canvas styles (lava renamed from \"blobs\", circle from \"circles\"). */\nexport const CANVAS_STYLES: CanvasStyle[] = [\n \"gradient\",\n \"lava\",\n \"circle\",\n];\n\n/** Ramp axes shared by the Gradient background and the Canvas (lightness = the \"linear\" ramp). */\nexport const RAMP_AXES: RampGradientAxis[] = [\n \"tonal\",\n \"hue\",\n \"lightness\",\n \"chroma\",\n];\n\n/** Gradient painting shapes the Gradient background cycles through. */\nexport const GRADIENT_SHAPES: GradientShape[] = [\n \"linear\",\n \"radial\",\n \"conic\",\n];\n\n/** Center positions (radial + conic) the switcher cycles through. */\nexport const GRADIENT_POSITIONS: {\n value: string;\n label: string;\n}[] = [\n {\n value: \"50% 50%\",\n label: \"center\",\n },\n {\n value: \"0% 0%\",\n label: \"top-left\",\n },\n {\n value: \"100% 0%\",\n label: \"top-right\",\n },\n {\n value: \"100% 100%\",\n label: \"bottom-right\",\n },\n {\n value: \"0% 100%\",\n label: \"bottom-left\",\n },\n];\n\n/** The position values the cycler steps through (labels live in GRADIENT_POSITIONS). */\nconst GRADIENT_POSITION_VALUES = GRADIENT_POSITIONS.map((p) => p.value);\n\n/** Radial shape + size options. */\nexport const RADIAL_SHAPES: NonNullable[] = [\n \"circle\",\n \"ellipse\",\n];\nexport const RADIAL_SIZES: NonNullable[] = [\n \"farthest-corner\",\n \"farthest-side\",\n \"closest-corner\",\n \"closest-side\",\n];\n\n/** Angle presets — 45° steps around the circle, shared by the gradient + canvas angle cyclers. */\nconst ANGLES = [\n 0,\n 45,\n 90,\n 135,\n 180,\n 225,\n 270,\n 315,\n];\n\n/** Steps-per-side presets the canvas/ramps cycle through (4–12). */\nexport const CANVAS_STEPS = [\n 4,\n 6,\n 8,\n 10,\n 12,\n] as const;\n\n/** Animation pace presets. */\nexport const CANVAS_SPEEDS = [\n 0.5,\n 1,\n 2,\n] as const;\n\n/** Animated-pattern pace: loop/scroll duration in SECONDS (2s fastest … 16s slowest); 0 = static.\n * Shared by every animated pattern (synthwave, moonrise, clouds, dune, aurora, chase) via the --pat-dur CSS var. */\nexport const PATTERN_SPEEDS = [\n 2,\n 4,\n 8,\n 12,\n 16,\n 0,\n] as const;\n\n/** Sun/moon horizontal placement for the horizon scenes (synthwave / moonrise / clouds / dune) — off-center\n * clears the page's centered content. */\nexport const PATTERN_DISCS = [\n \"right\",\n \"center\",\n \"left\",\n] as const;\n\n/** Blowing-sand intensity for the dune scene — \"still\" is a calm desert (no airborne sand at all),\n * distinct from the speed control's \"static\", which only pauses the motion that is already there. */\nexport const PATTERN_SANDS = [\n \"breeze\",\n \"storm\",\n \"still\",\n] as const;\n\nfunction persistBackground(next: BackgroundType) {\n try {\n localStorage.setItem(STORAGE_KEY, next);\n } catch {\n // ignore storage failures (private mode, etc.)\n }\n}\n\nfunction pickRandom(items: readonly T[]): T {\n return items[Math.floor(Math.random() * items.length)];\n}\n\nfunction cycle(items: readonly T[], current: T): T {\n const i = items.indexOf(current);\n return items[(i + 1) % items.length];\n}\n\n/**\n * State that steps through a fixed preset list, wrapping at the end. Returns the value, a stable\n * advance-to-the-next-preset callback, and the raw setter.\n */\nfunction useCycle(\n items: readonly T[],\n initial: T,\n): [\n T,\n () => void,\n React.Dispatch>,\n] {\n const [value, setValue] = React.useState(initial);\n const next = React.useCallback(\n () => setValue((v) => cycle(items, v)),\n [\n items,\n ],\n );\n return [\n value,\n next,\n setValue,\n ];\n}\n\ninterface BackgroundContextValue {\n background: BackgroundType;\n setBackground: (background: BackgroundType) => void;\n /** The ramp axis driving the gradient background. */\n gradientAxis: RampGradientAxis;\n /** Switch to the gradient background and set its ramp axis. */\n setGradientAxis: (axis: RampGradientAxis) => void;\n /** Gradient angle in degrees (90 = left → right). */\n gradientAngle: number;\n /** Rotate the gradient by 45°. */\n cycleGradientAngle: () => void;\n /** The gradient painting shape (linear | radial | conic). */\n gradientShape: GradientShape;\n /** Advance to the next gradient shape. */\n cycleGradientShape: () => void;\n /** Center position for radial + conic gradients (CSS `at `). */\n gradientPosition: string;\n /** Advance to the next center-position preset. */\n cycleGradientPosition: () => void;\n /** Radial shape (circle | ellipse). */\n radialShape: NonNullable;\n /** Toggle the radial shape. */\n cycleRadialShape: () => void;\n /** Radial size keyword. */\n radialSize: NonNullable;\n /** Advance to the next radial size. */\n cycleRadialSize: () => void;\n // ── Canvas ──\n /** The canvas style (gradient | lava | circle). */\n canvasStyle: CanvasStyle;\n /** Switch to the canvas background and set its style. */\n setCanvasStyle: (style: CanvasStyle) => void;\n /** The ramp axis driving the canvas colors. */\n canvasRamp: RampGradientAxis;\n /** Switch to the canvas background and set its ramp axis. */\n setCanvasRamp: (axis: RampGradientAxis) => void;\n /** Steps per side (4–12). */\n canvasSteps: number;\n /** Advance to the next steps-per-side preset. */\n cycleCanvasSteps: () => void;\n /** Canvas gradient angle in degrees. */\n canvasAngle: number;\n /** Rotate the canvas gradient by 45°. */\n cycleCanvasAngle: () => void;\n /** Canvas animation pace. */\n canvasSpeed: number;\n /** Advance to the next animation-pace preset. */\n cycleCanvasSpeed: () => void;\n /** Whether the canvas is currently animating. */\n canvasAnimated: boolean;\n /** Toggle canvas animation on/off. */\n toggleCanvasAnimated: () => void;\n /** Switch to the canvas background and reshuffle it (new style + ramp + layout seed). */\n shuffleCanvas: () => void;\n // ── Pattern (pure-CSS wallpapers) ──\n /** The active CSS pattern style. */\n patternStyle: PatternStyle;\n /** Switch to the pattern background and set its style. */\n setPatternStyle: (style: PatternStyle) => void;\n /** Advance to the next pattern style. */\n cyclePatternStyle: () => void;\n /** Star-field density (sparse | medium | dense). */\n patternDensity: PatternDensity;\n /** Advance to the next star-field density. */\n cyclePatternDensity: () => void;\n /** Horizon-grid scroll pace (synthwave / moonrise); 0 is static. */\n patternSpeed: number;\n /** Advance to the next grid scroll pace (… → static). */\n cyclePatternSpeed: () => void;\n /** Sun/moon placement for the horizon scenes (right | center | left). */\n patternDisc: (typeof PATTERN_DISCS)[number];\n /** Advance to the next sun/moon placement. */\n cyclePatternDisc: () => void;\n /** Blowing-sand intensity for the dune scene (breeze | storm | still). */\n patternSand: (typeof PATTERN_SANDS)[number];\n /** Advance to the next blowing-sand level. */\n cyclePatternSand: () => void;\n // ── Base color (the \"none\" backdrop) ──\n /** Base color for the \"none\" backdrop; null = follow the theme (tint-tinted, light/dark aware). */\n baseColor: string | null;\n /** Override the \"none\" backdrop color; null clears back to the themed default. */\n setBaseColor: (color: string | null) => void;\n}\n\nconst BackgroundContext = React.createContext(null);\n\nexport function useBackground() {\n const context = React.useContext(BackgroundContext);\n if (!context) {\n throw new Error(\"useBackground must be used within a BackgroundProvider\");\n }\n return context;\n}\n\ninterface RenderArgs {\n gradientAxis: RampGradientAxis;\n gradientAngle: number;\n gradientShape: GradientShape;\n gradientPosition: string;\n radialShape: NonNullable;\n radialSize: NonNullable;\n canvasStyle: CanvasStyle;\n canvasRamp: RampGradientAxis;\n canvasSteps: number;\n canvasAngle: number;\n canvasSpeed: number;\n canvasAnimated: boolean;\n canvasSeed: string;\n patternStyle: PatternStyle;\n patternDensity: PatternDensity;\n patternSpeed: number;\n patternDisc: (typeof PATTERN_DISCS)[number];\n patternSand: (typeof PATTERN_SANDS)[number];\n baseColor: string | null;\n}\n\nfunction renderBackground(background: BackgroundType, args: RenderArgs) {\n switch (background) {\n case \"canvas\":\n // key forces a clean redraw when the style/ramp/steps/seed/animation change\n return (\n \n );\n case \"pattern\":\n return (\n \n );\n case \"none\":\n // Forgo the decorative backdrop — a clean solid that honors the theme (tint hue, light/dark) by\n // default, overridable by the user's chosen base color.\n return (\n <>\n \n \n \n );\n default:\n return (\n \n );\n }\n}\n\n/**\n * Holds the site-wide background choice (persisted to localStorage) and renders it\n * behind the app. Pair with to preview + tune each style.\n */\nexport function BackgroundProvider({ children }: { children: React.ReactNode }) {\n // SSR + first client render use \"gradient\" so hydration matches; localStorage is read after mount.\n const [background, setBackgroundState] = React.useState(\"pattern\");\n const [gradientAxis, setGradientAxisState] = React.useState(\"tonal\");\n const [gradientAngle, cycleGradientAngle] = useCycle(ANGLES, 90);\n const [gradientShape, cycleGradientShape] = useCycle(GRADIENT_SHAPES, \"linear\");\n const [gradientPosition, cycleGradientPosition] = useCycle(GRADIENT_POSITION_VALUES, \"50% 50%\");\n const [radialShape, cycleRadialShape] = useCycle(RADIAL_SHAPES, \"circle\");\n const [radialSize, cycleRadialSize] = useCycle(RADIAL_SIZES, \"farthest-corner\");\n const [canvasStyle, setCanvasStyleState] = React.useState(\"gradient\");\n const [canvasRamp, setCanvasRampState] = React.useState(\"tonal\");\n const [canvasSteps, cycleCanvasSteps] = useCycle(CANVAS_STEPS, 6);\n const [canvasAngle, cycleCanvasAngle] = useCycle(ANGLES, 90);\n const [canvasSpeed, cycleCanvasSpeed] = useCycle(CANVAS_SPEEDS, 1);\n const [canvasAnimated, setCanvasAnimated] = React.useState(false);\n const [canvasSeed, setCanvasSeed] = React.useState(\"sistine\");\n const [patternStyle, cyclePatternStyleState, setPatternStyleState] = useCycle(PATTERN_STYLES, \"moonrise\");\n const [patternDensity, cyclePatternDensityState] = useCycle(PATTERN_DENSITIES, \"medium\");\n /* Static by DEFAULT (0 = no animation, the last entry in PATTERN_SPEEDS). The pattern layer is a\n backdrop for reading glass against, and a moving one competes with the content for attention on\n first load — motion should be something you opt into, not something you have to switch off. The\n speed control still cycles 2/4/8/12/16/0, so the animation is one click away. */\n const [patternSpeed, cyclePatternSpeedState] = useCycle(PATTERN_SPEEDS, 0);\n const [baseColor, setBaseColor] = React.useState(null);\n const [patternDisc, cyclePatternDiscState] = useCycle(PATTERN_DISCS, \"right\");\n const [patternSand, cyclePatternSandState] = useCycle(PATTERN_SANDS, \"breeze\");\n\n React.useEffect(() => {\n const stored = localStorage.getItem(STORAGE_KEY);\n if (stored === \"gradient\" || stored === \"canvas\" || stored === \"pattern\" || stored === \"none\") {\n setBackgroundState(stored);\n }\n }, []);\n\n /** The coupling every per-style setter/cycler shares: switch to that background type, apply, persist. */\n const withType =\n (type: BackgroundType, fn: (...args: A) => void) =>\n (...args: A) => {\n setBackgroundState(type);\n fn(...args);\n persistBackground(type);\n };\n\n const value: BackgroundContextValue = {\n background,\n setBackground: (next) => {\n setBackgroundState(next);\n persistBackground(next);\n },\n gradientAxis,\n setGradientAxis: withType(\"gradient\", setGradientAxisState),\n gradientAngle,\n cycleGradientAngle,\n gradientShape,\n cycleGradientShape,\n gradientPosition,\n cycleGradientPosition,\n radialShape,\n cycleRadialShape,\n radialSize,\n cycleRadialSize,\n canvasStyle,\n setCanvasStyle: withType(\"canvas\", setCanvasStyleState),\n canvasRamp,\n setCanvasRamp: withType(\"canvas\", setCanvasRampState),\n canvasSteps,\n cycleCanvasSteps,\n canvasAngle,\n cycleCanvasAngle,\n canvasSpeed,\n cycleCanvasSpeed,\n canvasAnimated,\n toggleCanvasAnimated: () => setCanvasAnimated((on) => !on),\n shuffleCanvas: withType(\"canvas\", () => {\n setCanvasStyleState(pickRandom(CANVAS_STYLES));\n setCanvasRampState(pickRandom(RAMP_AXES));\n setCanvasSeed(Math.random().toString(36).slice(2));\n }),\n patternStyle,\n setPatternStyle: withType(\"pattern\", setPatternStyleState),\n cyclePatternStyle: withType(\"pattern\", cyclePatternStyleState),\n patternDensity,\n cyclePatternDensity: withType(\"pattern\", cyclePatternDensityState),\n patternSpeed,\n cyclePatternSpeed: withType(\"pattern\", cyclePatternSpeedState),\n patternDisc,\n cyclePatternDisc: withType(\"pattern\", cyclePatternDiscState),\n patternSand,\n cyclePatternSand: withType(\"pattern\", cyclePatternSandState),\n baseColor,\n setBaseColor,\n };\n\n return (\n \n {renderBackground(background, {\n gradientAxis,\n gradientAngle,\n gradientShape,\n gradientPosition,\n radialShape,\n radialSize,\n canvasStyle,\n canvasRamp,\n canvasSteps,\n canvasAngle,\n canvasSpeed,\n canvasAnimated,\n canvasSeed,\n patternStyle,\n patternDensity,\n patternSpeed,\n patternDisc,\n patternSand,\n baseColor,\n })}\n {children}\n \n );\n}\n", "type": "registry:component", "target": "components/background-provider.tsx" }, diff --git a/public/r/canvas-background.json b/public/r/canvas-background.json index 2e62a51..980bc14 100644 --- a/public/r/canvas-background.json +++ b/public/r/canvas-background.json @@ -12,7 +12,7 @@ "files": [ { "path": "components/canvas-background.tsx", - "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { backdropPalette, type CanvasRamp, type CanvasStyle, createCanvas, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport { readableLightnessBand } from \"@/lib/oklch-utils\";\n\ninterface CanvasBackgroundProps {\n /** Canvas style: gradient | lava | circle. */\n style?: CanvasStyle;\n /** Hue override (deg). When set, drives the canvas color instead of the live --glass-tint-h. */\n hue?: number;\n /** Ramp axis the colors follow (lightness = \"linear\"). */\n ramp?: CanvasRamp;\n /** Steps per side (4–12). */\n steps?: number;\n /** Gradient angle in degrees (gradient style). */\n angle?: number;\n /** Animation pace (0 = static). */\n speed?: number;\n /** Whether to animate. */\n animated?: boolean;\n /** Seed for deterministic placement. */\n seed?: string;\n className?: string;\n /** Opacity of the background (0–1). */\n opacity?: number;\n /** Blur the canvas. */\n blur?: boolean;\n}\n\nexport function CanvasBackground({\n style: canvasStyle = \"gradient\",\n hue,\n ramp = \"tonal\",\n steps = 6,\n angle = 90,\n speed = 1,\n animated = false,\n seed,\n className = \"\",\n opacity = 1,\n blur = false,\n}: CanvasBackgroundProps) {\n const canvasRef = React.useRef(null);\n const frameRef = React.useRef(undefined);\n const [dimensions, setDimensions] = React.useState({\n width: 1920,\n height: 1080,\n dpr: 1,\n });\n // Live base color tracks the glass tint, so the canvas recolors with the theme (shared with the\n // CSS gradient background).\n const tint = useBackdropTint();\n\n React.useEffect(() => {\n const update = () => {\n // Back the canvas at device resolution (capped 2×) so it isn't upscaled/blurry on HiDPI.\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n setDimensions({\n width: Math.round(window.innerWidth * dpr),\n height: Math.round(window.innerHeight * dpr),\n dpr,\n });\n };\n update();\n window.addEventListener(\"resize\", update);\n return () => window.removeEventListener(\"resize\", update);\n }, []);\n\n React.useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\", {\n colorSpace: \"display-p3\",\n });\n if (!ctx) return;\n canvas.width = dimensions.width;\n canvas.height = dimensions.height;\n\n // Shared with GradientBackground: theme-tracking base + fresco palette (`hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n const { step } = createCanvas({\n width: dimensions.width,\n height: dimensions.height,\n dpr: dimensions.dpr,\n color: base,\n colors: frescoColors,\n style: canvasStyle,\n ramp,\n steps,\n // Same readable band the CSS gradient backdrop uses, so switching backgrounds keeps text legible.\n band: tint.fg ? readableLightnessBand(tint.fg, base) : undefined,\n angle,\n speed,\n p3: tint.p3,\n seed: seed || window.location.pathname,\n });\n\n if (animated && speed > 0) {\n let last = 0;\n const paint = (now: number) => {\n frameRef.current = requestAnimationFrame(paint);\n // ~30fps cap — the gradient repaint / gooey blur are costly and the motion is slow.\n if (now - last < 33) return;\n last = now;\n step(ctx, now / 1000);\n };\n frameRef.current = requestAnimationFrame(paint);\n } else {\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n step(ctx, 0);\n }\n\n return () => {\n if (frameRef.current) cancelAnimationFrame(frameRef.current);\n };\n }, [\n dimensions,\n tint,\n hue,\n canvasStyle,\n ramp,\n steps,\n angle,\n speed,\n animated,\n seed,\n ]);\n\n return (\n \n );\n}\n", + "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { backdropPalette, type CanvasRamp, type CanvasStyle, createCanvas, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport { readableLightnessBand } from \"@/lib/oklch-utils\";\n\ninterface CanvasBackgroundProps {\n /** Canvas style: gradient | lava | circle. */\n style?: CanvasStyle;\n /** Hue override (deg). When set, drives the canvas color instead of the live --glass-tint-h. */\n hue?: number;\n /** Ramp axis the colors follow (lightness = \"linear\"). */\n ramp?: CanvasRamp;\n /** Steps per side (4–12). */\n steps?: number;\n /** Gradient angle in degrees (gradient style). */\n angle?: number;\n /** Animation pace (0 = static). */\n speed?: number;\n /** Whether to animate. */\n animated?: boolean;\n /** Seed for deterministic placement. */\n seed?: string;\n className?: string;\n /** Opacity of the background (0–1). */\n opacity?: number;\n /** Blur the canvas. */\n blur?: boolean;\n}\n\nexport function CanvasBackground({\n style: canvasStyle = \"gradient\",\n hue,\n ramp = \"tonal\",\n steps = 6,\n angle = 90,\n speed = 1,\n animated = false,\n seed,\n className = \"\",\n opacity = 1,\n blur = false,\n}: CanvasBackgroundProps) {\n const canvasRef = React.useRef(null);\n const frameRef = React.useRef(undefined);\n const [dimensions, setDimensions] = React.useState({\n width: 1920,\n height: 1080,\n dpr: 1,\n });\n // Live base color tracks the glass tint, so the canvas recolors with the theme (shared with the\n // CSS gradient background).\n const tint = useBackdropTint();\n\n React.useEffect(() => {\n const update = () => {\n // Back the canvas at device resolution (capped 2×) so it isn't upscaled/blurry on HiDPI.\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n setDimensions({\n width: Math.round(window.innerWidth * dpr),\n height: Math.round(window.innerHeight * dpr),\n dpr,\n });\n };\n update();\n window.addEventListener(\"resize\", update);\n return () => window.removeEventListener(\"resize\", update);\n }, []);\n\n React.useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\", {\n colorSpace: \"display-p3\",\n });\n if (!ctx) return;\n canvas.width = dimensions.width;\n canvas.height = dimensions.height;\n\n // Shared with GradientBackground: theme-tracking base + fresco palette (`hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n const { step } = createCanvas({\n width: dimensions.width,\n height: dimensions.height,\n dpr: dimensions.dpr,\n color: base,\n colors: frescoColors,\n style: canvasStyle,\n ramp,\n steps,\n // Same readable band the CSS gradient backdrop uses, so switching backgrounds keeps text legible.\n band: tint.fg ? readableLightnessBand(tint.fg, base) : undefined,\n angle,\n speed,\n p3: tint.p3,\n seed: seed || window.location.pathname,\n });\n\n if (animated && speed > 0) {\n let last = 0;\n const paint = (now: number) => {\n frameRef.current = requestAnimationFrame(paint);\n // ~30fps cap — the gradient repaint / gooey blur are costly and the motion is slow.\n if (now - last < 33) return;\n last = now;\n step(ctx, now / 1000);\n };\n frameRef.current = requestAnimationFrame(paint);\n } else {\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n step(ctx, 0);\n }\n\n return () => {\n if (frameRef.current) cancelAnimationFrame(frameRef.current);\n };\n }, [\n dimensions,\n tint,\n hue,\n canvasStyle,\n ramp,\n steps,\n angle,\n speed,\n animated,\n seed,\n ]);\n\n return (\n \n );\n}\n", "type": "registry:component", "target": "components/canvas-background.tsx" } diff --git a/public/r/gradient-background.json b/public/r/gradient-background.json index a8f4fd8..07bd254 100644 --- a/public/r/gradient-background.json +++ b/public/r/gradient-background.json @@ -12,7 +12,7 @@ "files": [ { "path": "components/gradient-background.tsx", - "content": "\"use client\";\n\nimport { backdropPalette, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport {\n bandedFrescoStops,\n type GradientGeometry,\n type GradientShape,\n type RampGradientAxis,\n rampGradient,\n readableLightnessBand,\n wrapGradient,\n} from \"@/lib/oklch-utils\";\n\n/**\n * A ramp-driven gradient wallpaper. The gradient is one of our oklch ramps (hue / lightness / tonal\n * / chroma) centered on the live glass-tint color (so it recolors with the theme) with the center as a\n * slightly wider plateau, painted as a `linear`, `radial`, or `conic` gradient. Pure CSS — crisp at any DPI.\n */\nexport function GradientBackground({\n axis = \"tonal\",\n hue,\n angle = 90,\n shape = \"linear\",\n position,\n radialShape,\n radialSize,\n}: {\n axis?: RampGradientAxis;\n /** Hue override (deg). When set, drives the gradient color instead of the live --glass-tint-h. */\n hue?: number;\n angle?: number;\n shape?: GradientShape;\n} & GradientGeometry) {\n const tint = useBackdropTint();\n // Shared with CanvasBackground: theme-tracking base + fresco palette (the `hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n /* The ramps span their FULL range by design — as a wallpaper that put pure black at one edge of the\n viewport and pure white at the other, so a single solved foreground could only be readable at one\n end. Fit the ramp into the band where text over it clears APCA 60 instead. Falls back to the whole\n range if the foreground has not resolved yet (first paint, before AutoForeground runs). */\n const band = tint.fg ? readableLightnessBand(tint.fg, base) : undefined;\n const gradient = frescoColors\n ? wrapGradient(shape, bandedFrescoStops(frescoColors, band, shape), {\n angle,\n position,\n radialShape,\n radialSize,\n })\n : rampGradient(axis, base, 5, {\n angle,\n band,\n gamut: tint.p3 ? \"p3\" : \"srgb\",\n shape,\n position,\n radialShape,\n radialSize,\n });\n\n return (\n \n {/* Subtle organic highlight/shadow for depth — plain alpha layers, NO mix-blend-mode: a\n blend-mode element on the page can disable backdrop-filter RENDERING in Chromium, killing\n every glass blur above it (computed styles stay correct, paint silently fails). */}\n \n
\n );\n}\n", + "content": "\"use client\";\n\nimport { backdropPalette, useBackdropTint } from \"@/lib/canvas-background-utils\";\nimport {\n bandedFrescoStops,\n type GradientGeometry,\n type GradientShape,\n type RampGradientAxis,\n rampGradient,\n readableLightnessBand,\n wrapGradient,\n} from \"@/lib/oklch-utils\";\n\n/**\n * A ramp-driven gradient wallpaper. The gradient is one of our oklch ramps (hue / lightness / tonal\n * / chroma) centered on the live glass-tint color (so it recolors with the theme) with the center as a\n * slightly wider plateau, painted as a `linear`, `radial`, or `conic` gradient. Pure CSS — crisp at any DPI.\n */\nexport function GradientBackground({\n axis = \"tonal\",\n hue,\n angle = 90,\n shape = \"linear\",\n position,\n radialShape,\n radialSize,\n}: {\n axis?: RampGradientAxis;\n /** Hue override (deg). When set, drives the gradient color instead of the live --glass-tint-h. */\n hue?: number;\n angle?: number;\n shape?: GradientShape;\n} & GradientGeometry) {\n const tint = useBackdropTint();\n // Shared with CanvasBackground: theme-tracking base + fresco palette (the `hue` prop overrides both).\n const { base, frescoColors } = backdropPalette(tint, hue);\n /* The ramps span their FULL range by design — as a wallpaper that put pure black at one edge of the\n viewport and pure white at the other, so a single solved foreground could only be readable at one\n end. Fit the ramp into the band where text over it clears APCA 60 instead. Falls back to the whole\n range if the foreground has not resolved yet (first paint, before AutoForeground runs). */\n const band = tint.fg ? readableLightnessBand(tint.fg, base) : undefined;\n const gradient = frescoColors\n ? wrapGradient(shape, bandedFrescoStops(frescoColors, band, shape), {\n angle,\n position,\n radialShape,\n radialSize,\n })\n : rampGradient(axis, base, 5, {\n angle,\n band,\n gamut: tint.p3 ? \"p3\" : \"srgb\",\n shape,\n position,\n radialShape,\n radialSize,\n });\n\n return (\n \n {/* Subtle organic highlight/shadow for depth — plain alpha layers, NO mix-blend-mode: a\n blend-mode element on the page can disable backdrop-filter RENDERING in Chromium, killing\n every glass blur above it (computed styles stay correct, paint silently fails). */}\n \n
\n );\n}\n", "type": "registry:component", "target": "components/gradient-background.tsx" } diff --git a/public/r/theme.json b/public/r/theme.json index 59554f1..a81c051 100644 --- a/public/r/theme.json +++ b/public/r/theme.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/theme/globals.css", - "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Solidify floor — the opaque backing UNDER sheer glass ───────────────────\n `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image\n layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on.\n It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE\n CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer\n (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the\n same surface; they only shared a token.\n Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right\n lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is\n not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes\n what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17\n scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with\n no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it\n pins both back to the opaque floor below and nothing changes there. */\n --glass-solidify-l: 92;\n /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather\n than 17 new per-preset numbers, derive from the ones each preset already declares: across the\n palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real\n ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot,\n turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their\n surface chroma; the dark-peaking arc keeps 80–86%. */\n --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65);\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode\n text contrast, and dark already clears the body floor on every preset with margin. Pinning to the\n opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only\n change: a lighter backing under dark glass would wash the whole mode out. */\n --glass-solidify-l: var(--glass-opaque-l);\n --glass-solidify-c-max: var(--glass-opaque-c-max);\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer\n material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque\n CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has\n the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */\n --glass-solidify-bg: oklch(\n calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%)\n min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now\n (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n/* The uniform EDGE applies to every adaptive panel, veiled or not — a veiled card under the opaque page\n still reads as a solid panel and needs the same outline. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n}\n/* The FOREGROUND remap is :not(.glass-veil) — the same carve-out crystal and chakra make, and the same\n one the --srf-solidify rule above already makes for this page style. A veiled card does not paint\n --glass-opaque-bg: glass-veil moves the background-COLOR channel to the neutral --veil-floor, so its\n text sits on the veil surface the BASE tokens are banded for, not on the opaque floor. Remapping it\n here handed those cards ink solved for a surface they do not have — visible as a hue jump, since the\n opaque floor is the one surface with nothing composited over it and so sits at the raw --glass-tint-h\n while every sheer surface lands a few degrees off it (measured on the /colors panel: 22.0 vs 14.6).\n Splitting the rule is what keeps the edge treatment while dropping the foreground one. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", + "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Solidify floor — the opaque backing UNDER sheer glass ───────────────────\n `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image\n layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on.\n It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE\n CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer\n (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the\n same surface; they only shared a token.\n Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right\n lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is\n not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes\n what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17\n scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with\n no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it\n pins both back to the opaque floor below and nothing changes there. */\n --glass-solidify-l: 92;\n /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather\n than 17 new per-preset numbers, derive from the ones each preset already declares: across the\n palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real\n ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot,\n turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their\n surface chroma; the dark-peaking arc keeps 80–86%. */\n --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65);\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode\n text contrast, and dark already clears the body floor on every preset with margin. Pinning to the\n opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only\n change: a lighter backing under dark glass would wash the whole mode out. */\n --glass-solidify-l: var(--glass-opaque-l);\n --glass-solidify-c-max: var(--glass-opaque-c-max);\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer\n material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque\n CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has\n the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */\n --glass-solidify-bg: oklch(\n calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%)\n min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now\n (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* THE PAGE BACKDROP LAYER — the fixed, full-viewport ground every sheer material blurs. Put this on the\n `fixed inset-0 -z-10` container each background component renders.\n It exists to force a compositing layer. `backdrop-filter` re-samples everything painted beneath it,\n and this layer sits at z-index -10 under the entire document; unpromoted, the compositor has to\n re-rasterise it against the scroll position every frame, which is where the scroll artifact on the\n sheer materials comes from. `opaque` is the ONE material that sets --srf-filter: none — and the one\n material that never showed the artifact, which is the correlation that points here.\n Measured on the deployed page, this element resolved will-change:auto / transform:none /\n contain:none — nothing was promoting it. Promoting is safe specifically because it has ZERO\n position:fixed descendants and no background-attachment:fixed; a transform/will-change would\n otherwise become their containing block and reparent them. Re-check that before adding children.\n will-change (rather than a translateZ hack) because the hint is honest here: this layer is on screen\n and being sampled for the whole session, so there is nothing to hint OFF later. */\n@utility glass-backdrop-layer {\n will-change: transform;\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n/* The uniform EDGE applies to every adaptive panel, veiled or not — a veiled card under the opaque page\n still reads as a solid panel and needs the same outline. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n}\n/* The FOREGROUND remap is :not(.glass-veil) — the same carve-out crystal and chakra make, and the same\n one the --srf-solidify rule above already makes for this page style. A veiled card does not paint\n --glass-opaque-bg: glass-veil moves the background-COLOR channel to the neutral --veil-floor, so its\n text sits on the veil surface the BASE tokens are banded for, not on the opaque floor. Remapping it\n here handed those cards ink solved for a surface they do not have — visible as a hue jump, since the\n opaque floor is the one surface with nothing composited over it and so sits at the raw --glass-tint-h\n while every sheer surface lands a few degrees off it (measured on the /colors panel: 22.0 vs 14.6).\n Splitting the rule is what keeps the edge treatment while dropping the foreground one. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", "type": "registry:style", "target": "app/globals.css" } diff --git a/registry/theme/globals.css b/registry/theme/globals.css index 5895dc3..5e6a121 100644 --- a/registry/theme/globals.css +++ b/registry/theme/globals.css @@ -1203,6 +1203,23 @@ --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a)); } +/* THE PAGE BACKDROP LAYER — the fixed, full-viewport ground every sheer material blurs. Put this on the + `fixed inset-0 -z-10` container each background component renders. + It exists to force a compositing layer. `backdrop-filter` re-samples everything painted beneath it, + and this layer sits at z-index -10 under the entire document; unpromoted, the compositor has to + re-rasterise it against the scroll position every frame, which is where the scroll artifact on the + sheer materials comes from. `opaque` is the ONE material that sets --srf-filter: none — and the one + material that never showed the artifact, which is the correlation that points here. + Measured on the deployed page, this element resolved will-change:auto / transform:none / + contain:none — nothing was promoting it. Promoting is safe specifically because it has ZERO + position:fixed descendants and no background-attachment:fixed; a transform/will-change would + otherwise become their containing block and reparent them. Re-check that before adding children. + will-change (rather than a translateZ hack) because the hint is honest here: this layer is on screen + and being sampled for the whole session, so there is nothing to hint OFF later. */ +@utility glass-backdrop-layer { + will-change: transform; +} + /* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */ From 2aaa7c04189fbc64c7d1aefb5f4574db71d0839d Mon Sep 17 00:00:00 2001 From: Weekendsuperhero <4048475+WeekendSuperhero@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:33:33 -0700 Subject: [PATCH 12/12] =?UTF-8?q?Good=20=E2=80=94=20glad=20the=20scroll=20?= =?UTF-8?q?fix=20landed.=20And=20you=20were=20right=20that=20I=20hadn't=20?= =?UTF-8?q?fixed=20this=20one:=20I'd=20fixed=20the=20card's=20*text*,=20yo?= =?UTF-8?q?u=20were=20describing=20its=20*surface*.=20Two=20separate=20cau?= =?UTF-8?q?ses,=20both=20now=20found.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opaque page style's `--glass-solid-a: 1` sat inside a `:where(…)` block — **zero specificity** — competing against `:root { --glass-solid-a: 0.65 }` at (0,1,0). Root won every time. Every other declaration in that block survives because nothing else declares them; they're material *slots*. `--glass-solid-a` is the only one with a competing `:root` value, so it's the only one that lost. Measured: `--glass-solid-a` resolved to `.65` on `` under `[data-glass="opaque"]`, identical to every other page style. Moved it to a bare `[data-glass="opaque"]` selector — (0,1,0), ties `:root`, wins on source order since `globals.css` imports `styles.css` after `tokens.css`. It's a plain literal so it just inherits into the tint/toast scopes. **This isn't a demo bug.** The comment right above that block says it exists so veiled menus and toasts "stay legible on an opaque page" — that has never worked. Any veiled overlay on an opaque page was staying translucent. `useState(0.65)` — hardcoded, never read from the page. So even with the CSS fixed, the card's inline `--glass-solid-a: 0.65` overrode it. Worse, it *modelled* the surface at 0.65 too, so the panel's Lc numbers were wrong under an opaque page while claiming to match production. Now seeded from the live `--glass-solid-a`, re-seeding only when the **page's** value changes so a dragged slider isn't clobbered. Third cause underneath that: the tester's `MutationObserver` watched `class`, `data-glass-tint`, `style` — but **not `data-glass`**, the page style itself. So it never noticed page-style changes at all. Added. ``` translucent root=.65 card inline=0.65 cardBg=oklch(0.18 0 0 / 0.65) opaque root=1 card inline=1 cardBg=oklch(0.18 0 0) ← solid translucent root=.65 card inline=0.65 cardBg=oklch(0.18 0 0 / 0.65) ← re-seeds back ``` Screenshot confirms it: no starfield through the card, slider reads "Solid 100%". One thing that is *not* a bug: the card stays neutral grey rather than tinted, where the panels around it are tinted. That's `glass-veil` doing its job — a neutral solid ground — and the tester uses it deliberately, because the veiled floor is the surface `glassSolidSurface` models. Tinting it would make the Lc numbers stop matching production. ``` ✓ 175 tests · lint · typecheck · contrast · gamut · APCA · theme (123 rules) · registry ``` --- app/theme/styles.css | 13 +++++++++++++ components/foreground-tester.tsx | 24 ++++++++++++++++++++++++ public/r/theme.json | 2 +- registry/theme/globals.css | 13 +++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/theme/styles.css b/app/theme/styles.css index ec01d98..802d4a5 100644 --- a/app/theme/styles.css +++ b/app/theme/styles.css @@ -93,6 +93,19 @@ --srf-bg-color: var(--glass-opaque-bg); --srf-filter: none; --srf-shadow: var(--glass-shadow); +} +/* --glass-solid-a CANNOT ride in the :where() block above. :where() is specificity ZERO by design (so a + page style never beats an explicit material), and every other declaration up there survives that + because nothing else declares them — they are material SLOTS with no competing value. This one is + different: tokens.css declares --glass-solid-a on :root, which is (0,1,0), so the :where() version + lost every time and the opaque page never actually got its solid floor. Measured: --glass-solid-a + resolved to 0.65 on under [data-glass="opaque"], identical to every other page style. + The visible symptom is any VEILED surface on an opaque page staying translucent while the solid + panels around it go opaque — menus, toasts and popovers, which is exactly what the block above says + this is for. A bare attribute selector is (0,1,0), which ties :root and wins on source order because + globals.css imports styles.css after tokens.css. It is a plain literal, so it simply inherits into + the tint / toast scopes and needs none of their re-resolution. */ +[data-glass="opaque"] { --glass-solid-a: 1; } /* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material — diff --git a/components/foreground-tester.tsx b/components/foreground-tester.tsx index 6e0d92d..97e7858 100644 --- a/components/foreground-tester.tsx +++ b/components/foreground-tester.tsx @@ -111,6 +111,11 @@ const WEIGHTS = [ * readableForeground demo — lightness solved for the ui band at an optional hue. App-only. */ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: { live?: boolean; palettes?: FgPalette[] } = {}) { + /* Seeded FROM the page, not hardcoded. The opaque page style sets --glass-solid-a: 1 so veiled + surfaces go solid; pinning 0.65 here left this card translucent while every panel around it went + opaque, AND modelled the wrong surface — the panel claims its Lc matches production, so the solid + dial has to start where production has it. The slider still overrides; it re-seeds only when the + PAGE's value changes (i.e. when the page style is switched), so dragging is never clobbered. */ const [solidA, setSolidA] = React.useState(0.65); const [palette, setPaletteState] = React.useState(palettes[0] ?? "lightness"); // Icon-hue: when `live`, writes fgConfig.iconHue → AutoForeground sets the site `--foreground-ui` @@ -162,6 +167,7 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: solidifyL: 36.4, solidifyC: 0, glassOpacity: 0.7, + pageSolidA: 0.65, }); React.useEffect(() => { @@ -191,6 +197,7 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: num("--glass-solidify-c-max", isDark ? opaqueCMax : opaqueCMax * 0.65), ), glassOpacity: Math.min(Math.max(num("--glass-opacity", 0.7), 0), 1), + pageSolidA: Math.min(Math.max(num("--glass-solid-a", 0.65), 0), 1), // Harmony anchor: matches CSS --harmony-h (content hue, or 0 for selenite/moonstone), so the chip hues here // land on the same angle as the --hue-* swatches. Falls back to the content hue when unset (jewels). harmonyH: num("--harmony-h", num("--glass-fg-h", num("--glass-tint-h", r.h))), @@ -210,6 +217,10 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: attributeFilter: [ "class", "data-glass-tint", + // data-glass is the PAGE STYLE, and it moves surface-model tokens — most visibly + // --glass-solid-a, which the opaque page pins to 1. Without it here the panel kept modelling + // (and rendering) the previous page style's surface until some other attribute happened to change. + "data-glass", "style", ], }); @@ -220,6 +231,19 @@ export function ForegroundTester({ live = false, palettes = DEFAULT_PALETTES }: }; }, []); + /* Re-seed the solid dial whenever the PAGE's value changes — switching to the opaque page style moves + --glass-solid-a to 1. Keyed on the page value (not on every render) so a user-dragged slider is left + alone until the page itself changes. */ + const lastPageSolidA = React.useRef(null); + React.useEffect(() => { + if (lastPageSolidA.current !== env.pageSolidA) { + lastPageSolidA.current = env.pageSolidA; + setSolidA(env.pageSolidA); + } + }, [ + env.pageSolidA, + ]); + /* The SAME surface AutoForeground bands against — full argument list. Passing only (dark, tint, solidA) silently drops the per-preset wash lightness AND the solidify floor, which is what made this panel disagree with the tokens it is meant to explain. */ diff --git a/public/r/theme.json b/public/r/theme.json index a81c051..55c6378 100644 --- a/public/r/theme.json +++ b/public/r/theme.json @@ -10,7 +10,7 @@ "files": [ { "path": "registry/theme/globals.css", - "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Solidify floor — the opaque backing UNDER sheer glass ───────────────────\n `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image\n layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on.\n It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE\n CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer\n (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the\n same surface; they only shared a token.\n Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right\n lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is\n not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes\n what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17\n scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with\n no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it\n pins both back to the opaque floor below and nothing changes there. */\n --glass-solidify-l: 92;\n /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather\n than 17 new per-preset numbers, derive from the ones each preset already declares: across the\n palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real\n ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot,\n turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their\n surface chroma; the dark-peaking arc keeps 80–86%. */\n --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65);\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode\n text contrast, and dark already clears the body floor on every preset with margin. Pinning to the\n opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only\n change: a lighter backing under dark glass would wash the whole mode out. */\n --glass-solidify-l: var(--glass-opaque-l);\n --glass-solidify-c-max: var(--glass-opaque-c-max);\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer\n material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque\n CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has\n the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */\n --glass-solidify-bg: oklch(\n calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%)\n min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now\n (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* THE PAGE BACKDROP LAYER — the fixed, full-viewport ground every sheer material blurs. Put this on the\n `fixed inset-0 -z-10` container each background component renders.\n It exists to force a compositing layer. `backdrop-filter` re-samples everything painted beneath it,\n and this layer sits at z-index -10 under the entire document; unpromoted, the compositor has to\n re-rasterise it against the scroll position every frame, which is where the scroll artifact on the\n sheer materials comes from. `opaque` is the ONE material that sets --srf-filter: none — and the one\n material that never showed the artifact, which is the correlation that points here.\n Measured on the deployed page, this element resolved will-change:auto / transform:none /\n contain:none — nothing was promoting it. Promoting is safe specifically because it has ZERO\n position:fixed descendants and no background-attachment:fixed; a transform/will-change would\n otherwise become their containing block and reparent them. Re-check that before adding children.\n will-change (rather than a translateZ hack) because the hint is honest here: this layer is on screen\n and being sampled for the whole session, so there is nothing to hint OFF later. */\n@utility glass-backdrop-layer {\n will-change: transform;\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n/* The uniform EDGE applies to every adaptive panel, veiled or not — a veiled card under the opaque page\n still reads as a solid panel and needs the same outline. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n}\n/* The FOREGROUND remap is :not(.glass-veil) — the same carve-out crystal and chakra make, and the same\n one the --srf-solidify rule above already makes for this page style. A veiled card does not paint\n --glass-opaque-bg: glass-veil moves the background-COLOR channel to the neutral --veil-floor, so its\n text sits on the veil surface the BASE tokens are banded for, not on the opaque floor. Remapping it\n here handed those cards ink solved for a surface they do not have — visible as a hue jump, since the\n opaque floor is the one surface with nothing composited over it and so sits at the raw --glass-tint-h\n while every sheer surface lands a few degrees off it (measured on the /colors panel: 22.0 vs 14.6).\n Splitting the rule is what keeps the edge treatment while dropping the foreground one. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", + "content": "/*\n Sistine theme — aggregator. The theme is authored as focused partials under app/theme/; Tailwind v4\n bundles these relative imports itself (no postcss-import needed). ORDER MATTERS:\n - package imports come first (CSS requires @import before other rules),\n - engine.css must precede presets.css / frescoes.css (a preset's --glass-tint-h overrides the\n engine default by SOURCE ORDER at equal specificity),\n - inside tokens.css, .dark comes after :root (same-element ties resolve by source order).\n Consumers install a FLATTENED single-file build of this graph (registry/theme/globals.css, generated\n by scripts/build-theme.mjs) — run `pnpm registry:check` after editing any partial and commit\n registry/theme + public/r.\n*/\n@import \"tailwindcss\";\n@import \"tw-animate-css\";\n/* ── Registrations — @custom-variant + @property pins for JS-only tokens ──────\n Loaded first so the dark variant and typed properties exist before anything composes them. */\n\n/* Class-based dark mode, v4-standard form: matches the `.dark` element ITSELF and its descendants, so\n consumers can toggle `.dark` on any wrapper (not just / next-themes), at zero specificity\n (`:where`) so `dark:` overrides by cascade order like every other variant. */\n@custom-variant dark (&:where(.dark, .dark *));\n\n/* Composable, NON-inheriting glow layer: the `glow` effect/prop sets it, and every glass box-shadow\n folds in `var(--glow-layer, …)` so the glow rides INSIDE each style's shadow — surviving the\n per-style `!important` box-shadow overrides instead of being replaced. inherits:false keeps a\n glowing surface from lighting up its glass children. */\n@property --glow-layer {\n syntax: \"*\";\n inherits: false;\n}\n\n/* Gradient-text tokens are consumed ONLY from JS inline styles (components/gradient-text.tsx) — no CSS\n rule references them, so Lightning CSS's optimizer would silently drop them (the same failure mode as\n the harmonic tokens below). Registered here to pin them. DO NOT REMOVE. */\n@property --gradient-text {\n syntax: \"*\";\n inherits: true;\n}\n@property --gradient-text-contrast {\n syntax: \"*\";\n inherits: true;\n}\n/* ── Harmonic color registrations — compacted one-liners (biome's CSS formatter is excluded for this\n file; see biome.json). WHY these exist: the comment below. */\n\n/* ── Harmonic token registration ──────────────────────────────────────────────────────────────\n The harmonic tokens (--harmony-h, --hue-*, --color-*, --mono-*, defined in engine.css) are\n consumed ONLY from JS / inline styles (HarmonicSwatchesDemo, AutoForeground) — never referenced by\n a CSS rule. Tailwind v4's Lightning CSS optimizer constant-folds + dead-code-eliminates any custom\n property no CSS rule uses, which silently dropped this ENTIRE subgraph from the COMPILED stylesheet\n (so every `oklch(L C var(--hue-*))` / `var(--color-*)` resolved to nothing at runtime). Registering\n them via @property marks them as first-class typed API the optimizer must preserve. DO NOT REMOVE. */\n@property --harmony-h { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --mono-chroma { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-base { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-complement { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-analogous-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-split-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-triad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-tetrad-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-1 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-2 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --hue-square-3 { syntax: \"\"; inherits: true; initial-value: 0; }\n@property --color-complement { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-analogous-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-split-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --color-triad-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-1 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-2 { syntax: \"\"; inherits: true; initial-value: transparent; }\n@property --mono-3 { syntax: \"\"; inherits: true; initial-value: transparent; }\n/* ── Design tokens — @theme mapping, base shadcn colors, mode knobs + shadow twins, base layer ─────\n :root carries light values + the LEAF mode knobs the engine composes; .dark carries the dark twins.\n .dark must come after :root (same-element ties resolve by source order on html.dark). */\n\n@theme inline {\n /* Easing. Tailwind's built-ins are deliberately gentle; these are the stronger variants UI motion\n wants, exposed as `ease-out` / `ease-in-out` / `ease-drawer` utilities.\n out — anything ENTERING or EXITING. Starts fast, so the first frame (the one the user is\n actually watching) already moves.\n in-out — things MOVING on screen that neither enter nor leave: carousels, sliders.\n drawer — a slower settle for panels that slide in from an edge, so a large surface arrives with\n weight instead of snapping.\n There is deliberately no `ease-in` token: it delays the first frame, which is the one moment the\n interface most needs to feel responsive. */\n --ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);\n --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);\n /* Anything with a bare `transition` and no `ease-*` inherits this. Tailwind ships\n cubic-bezier(0.4, 0, 0.2, 1) — symmetric, so it eases IN as well, and the first frame barely\n moves. Same curve as --ease-out, written literally because @theme inline doesn't emit the vars\n it inlines. */\n --default-transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-foreground-soft: var(--foreground-soft);\n --color-foreground-strong: var(--foreground-strong);\n --color-foreground-ui: var(--foreground-ui);\n --font-sans: var(--font-geist-sans);\n --font-mono: var(--font-geist-mono);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar: var(--sidebar);\n --color-chart-5: var(--chart-5);\n --color-chart-4: var(--chart-4);\n --color-chart-3: var(--chart-3);\n --color-chart-2: var(--chart-2);\n --color-chart-1: var(--chart-1);\n --color-ring: var(--ring);\n --color-input: var(--input);\n --color-border: var(--border);\n --color-destructive: var(--destructive);\n --color-accent-foreground: var(--accent-foreground);\n --color-accent: var(--accent);\n --color-muted-foreground: var(--muted-foreground);\n --color-muted: var(--muted);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-secondary: var(--secondary);\n --color-primary-foreground: var(--primary-foreground);\n --color-primary: var(--primary);\n --color-popover-foreground: var(--popover-foreground);\n --color-popover: var(--popover);\n --color-card-foreground: var(--card-foreground);\n --color-card: var(--card);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n --radius: 1.25rem;\n /* Shadow ink — the base black/white that every shadow, pressed-inset, and bevel highlight is built\n from. Mode-invariant (only the ALPHA changes per surface/mode), so apply alpha at the call site:\n oklch(var(--shadow-ink) / ). One knob to retint every shadow at once (e.g. a cooler, less-flat\n ink) instead of hunting down dozens of oklch(0% 0 0) / oklch(100% 0 0) literals. */\n --shadow-ink: 0% 0 0; /* drop + pressed-inset shadow color */\n --shadow-highlight: 100% 0 0; /* inner bevel / top highlight */\n /* Active/pressed shadow SCALE, built from the ink above and mode-aware (deeper in dark; overridden in\n .dark), so components reference these with NO dark: twin. Depth ladder — all inset \"push-in\" presses\n except --active-shadow, which LIFTS the selected tab/toggle. --press-shadow-strong shares the\n standard press's 0.2 primary and stacks a contact layer (was 0.15; raised so the two-layer presses\n build ON the single-layer one instead of undercutting it):\n --press-shadow-sm — subtle press (ghost, outline)\n --press-shadow — standard press (default, gradient, opaque)\n --press-shadow-strong — firm two-layer press (glass, destructive, secondary, frosted)\n --press-shadow-deep — deepest press (crystal)\n --active-shadow — selected-control LIFT (tabs, toggle) */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.1);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.2), inset 0 1px 2px oklch(var(--shadow-ink) / 0.15);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.25), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --active-shadow: 0 1px 3px oklch(var(--shadow-ink) / 0.1), inset 0 1px 1px oklch(var(--shadow-highlight) / 0.5);\n /* Selected-control FILL — the twin of --active-shadow, which had the lift but no surface, so tabs and\n toggle-group each hardcoded `bg-white/80 dark:bg-white/20`. The colour is composed in engine.css;\n these are its knobs.\n The two modes source their hue by DIFFERENT routes, and that asymmetry is the whole design.\n DARK is structurally theme-driven: the fill is chroma 0 at alpha 0.2, so 100% of the colour is the\n tinted surface showing through — the chip literally IS the theme surface, lifted. Nothing can make\n it drift off-theme.\n LIGHT cannot use that route. Show-through only carries hue if the surface has hue to give, and a\n light glass surface measures around chroma 0.018 (the sheet is itself capped by --glass-tint-c-hi\n at 0.025 near white). So at alpha 0.9 the fill supplies ~94% of the chip's colour and has to carry\n the tint ITSELF.\n Which makes the fill's own lightness the binding constraint, because the near-white ceiling\n collapses hue-dependently — 0.0323 at L 93, 0.061 at L 87, and only 0.0091 by L 98. At the old\n L 93 the cap was 0.031 and the chip delivered an RGB spread of 20: nominally theme-tinted, visibly\n neutral, which is why day did not read as theme-driven while night did. L 87 roughly doubles the\n available chroma and the delivered spread (37), at the cost of sitting 3.6 L BELOW the tab list\n rather than 1.8 above it — the lift is carried by --active-shadow, so the chip still reads\n selected, now as a tinted fill rather than a white one.\n Capped with min() like --border and the chakra body, so selenite (chroma 0) stays exactly neutral\n and no preset can push the fill out of gamut. */\n --active-l: 87%;\n --active-c-max: 0.055; /* ≤ 0.061, the L-87 ceiling floor (lapis h268) */\n --active-a: 0.9;\n /* ── Mode knobs ──────────────────────────────────────────────────────────\n Single-number dials the glass engine's mode-derived block composes (light values here, dark twins in\n .dark). LEAVES ONLY — none composes a tint var (check-theme invariant 1), so they may live on the bare\n blocks and inherit into tint scopes, where the engine re-resolves the derived tokens. */\n --glass-sheet-a: 0.11; /* sheer glass sheet base alpha (per-stop offsets ride on top) */\n --glass-sheet-a1: 0.04; /* the sheet's TOP-stop offset (dark tapers it to 0.03) */\n --glass-frost-boost: 0.1; /* frosted = the sheet lifted by this much */\n --glass-border-a: 0.16; /* glass edge alpha — a white rim needs more presence on light backdrops */\n /* Crystal gloss defaults (the demo's Light/Tint sliders override inline). --glass-gloss-tint is\n mode-shared; --glass-gloss-l is NOT — see its twin below. AutoForeground's crystal tiers mirror\n --glass-gloss-l (97 light / 66 dark) as their snapshot-path fallback — keep all three in step. */\n --glass-diffuse: 12px; /* readability blur FLOOR the glass-diffuse axis raises surfaces to (opaque ignores it) */\n /* Gloss ink lightness (\"Light\" in the demo) — a MODE TWIN, because a gloss is defined relative to\n the surface under it and those surfaces are 68 L apart. The crystal floor computes to L 96.5 in\n light and L 28 in dark, so a single shared value cannot be a highlight in both: at 66 the top stop\n lifts dark by +15.2 L (a specular) and darkens light by −12.2 L (a smudge — the same \"grey\n speculars read as grime\" failure the shadow bevel comment warns about).\n 97 is the light twin: composited it lands +0.2 L over the surface, which is all the headroom that\n exists above L 96.5. The cost is a chroma ceiling, and it is strongly HUE-dependent rather than\n uniform — at L 97 it ranges from 0.0686 for peridot (h128) down to 0.0136 for lapis (h268).\n CORRECTION, measured in WebKit: an over-ceiling oklch() is NOT gamut-mapped down to that ceiling.\n It is CLIPPED per channel, which lands FURTHER from the request, not nearer:\n oklch(97% 0.0686 128) -> 232,255,205 (the ceiling — what the model predicts)\n oklch(97% 0.2975 128) -> 185,255,0 (what actually shipped: pure chartreuse)\n So the Tint multiplier did not \"do real work up to each hue's own ceiling\"; above the ceiling it\n drove the ink to a fully saturated primary. Every preset was affected — turquoise and aquamarine\n both clipped to 0,255,255, i.e. the two were indistinguishable — and moonstone reached 255,222,33,\n the egg-yolk cast that made light mode read yellow. --glass-gloss-c-max below is the fix, sized the\n way --glass-chakra-c-max is: the TIGHTEST ceiling across every preset hue, so no preset clips and\n all of them deliver 100% of what they ask for. */\n --glass-gloss-l: 97;\n /* Gloss chroma cap — the L-97 twin. 0.0136 is lapis (h268), the tightest of the 17 presets; 0.013\n sits just under it. Light gloss is therefore near-achromatic by necessity, not by choice: sRGB\n simply holds no more colour that close to white, and asking for more clips rather than tints. */\n --glass-gloss-c-max: 0.013;\n /* Gloss chroma multiplier over the tint (\"Tint\"). Calibrated for the DEFAULT tint (0.018 × 4.25 =\n 0.077, comfortably in gamut); a preset multiplies up from a much higher base, so the product is\n clamped to 0.3 where --glass-gloss-ink composes it — sRGB holds ~0.32 chroma at its very best hue\n and far less at L=66, so beyond that the browser, not the theme, is picking the color. Jewels\n (0.07 × 4.25 = 0.298) sit under the clamp and are unaffected; only the status tints hit it. */\n --glass-gloss-tint: 4.25;\n --glass-wash-l: 72%; /* tint wash lightness — deeper in dark so the tint reads over dark backdrops */\n --glass-wash-c-mult: 2.5; /* tint wash chroma multiplier (moonstone night quiets it to 2) */\n --glass-accent-l: 0.6; /* accent envelope — lifted in dark so the hue pops */\n --glass-accent-c: 0.15;\n --glass-glow-lc: 0.62 0.2; /* glow L+C pair */\n --glass-glow-a: 0.45;\n --harmonic-l: 0.6; /* --color-* accent-tier lightness */\n --mono-l-1: 0.75; /* mono ramp steps (irregular light↔dark lifts — three dials, not one) */\n --mono-l-2: 0.6;\n --mono-l-3: 0.45;\n --gradient-l: 0.6; /* brand-gradient envelope (matches --glass-accent-l/-c) */\n --gradient-c: 0.15;\n --gradient-contrast-l: 0.32; /* contrast gradient text: dark-on-light here, light-on-dark in .dark */\n --gradient-contrast-c: 0.14;\n --glass-crystal-l: 96; /* crystal body lightness — see --glass-crystal-bg; 100 carries zero chroma */\n --glass-crystal-bg-a: 0.3; /* crystal floor alpha (near-clear in dark) */\n /* glass-solid floor — the \"how solid\" dial (0 = sheer, 1 = fully solid; shared by light/dark) + the\n mode floor lightness; composed in @utility glass-veil so a scoped --glass-solid-a resolves there. */\n --glass-solid-a: 0.65;\n --glass-solid-l: 99%;\n /* Opaque tint BODY lightness (plain number → % in the engine). The header L slider drives this; the\n opaque floor color AND AutoForeground's --foreground-opaque banding both read it, so they can't\n drift. LOWER = deeper tint.\n L AND the c-scale below together absorb the retired opaque SHEET (see materials.css). --glass-bg was a\n near-white gradient at ~0.11 alpha, so it did two things to this floor: lightened it AND desaturated\n it. Compensating with lightness alone reproduced only half of that — it left the floor too saturated,\n and pushing L further toward white also SHRINKS the sRGB chroma ceiling, which blew the opaque body\n past check-gamut's 4.6× budget (lapis hit 5.46×). Moving both knobs reproduces both effects and stays\n inside the budget at 4.47×. Fitted across the 14 presets: worst OKLab dE 0.0021, mean 0.0009 — an\n order of magnitude under a just-noticeable difference, so the surface is unchanged to the eye.\n Moonstone pins its own pair (presets.css); its floor is near-white, where the sheet did far less. */\n /* L88, not 90.9. Near-white is where the sRGB chroma ceiling collapses, so an opaque card at 90.9 had\n two problems at once: it read as washed-toward-white, and it could not hold enough chroma to carry\n its own tint (lapis and sapphire were at 97–99% of a ceiling worth only 0.043). Dropping 2.9 L\n roughly doubles every hue's ceiling while keeping body text at Lc 80.0 — the floor is still firmly\n on the light side, so text polarity never changes. */\n --glass-opaque-l: 88;\n /* 0.85, down from 1.26. The old value only looked large: it was immediately clipped to the shared\n 0.042 cap, so every preset rendered the SAME chroma regardless of its tint. Presets now pin their\n own --glass-opaque-c-max (their hue's real ceiling at L88), and 0.85 is the multiplier that lets\n each one reach it. Net effect vs the old flat 0.042: +7% to +88% depending on hue. */\n --glass-opaque-c-scale: 0.85;\n /* Opaque floor chroma cap — the near-white sibling of --glass-tint-c-hi / --glass-chakra-c-max /\n --glass-gloss-c-max, and the last surface that lacked one. Without a cap the floor multiplied raw\n tint chroma this close to white and clipped for most of the palette.\n FALLBACK ONLY: the 12 jewels each pin their own above (their hue's real ceiling at L88); this\n covers the 9 scopes that don't — the 4 frescoes, the 4 status tints and moonstone. Sizing the\n SHARED cap to the tightest hue is the mistake that came before: one number low enough for amber\n (h75, ceiling 0.036 at L88) left peridot at 18% of its own much larger ceiling. So this is\n deliberately not the tightest — per-hue pins are, and scripts/check-gamut.mjs sweeps every scope\n against its real ceiling rather than trusting this number. */\n --glass-opaque-c-max: 0.055;\n /* ── Solidify floor — the opaque backing UNDER sheer glass ───────────────────\n `glass` paints --glass-solidify (this colour at --glass-opacity) as the bottom background-image\n layer of every SHEER material, so it is ~70% of what page/crystal/chakra text actually sits on.\n It used to be --glass-opaque-bg itself, which conflated two different jobs: the colour an OPAQUE\n CARD shows, and the backing that sets sheer text contrast. Opaque cards explicitly drop this layer\n (materials.css, --srf-solidify: none — it would be C over C), so the two were never actually the\n same surface; they only shared a token.\n Splitting them is what lets the sheer floor rise without repainting opaque cards. It is the right\n lever for text contrast because it is the only one with real WEIGHT in the composite (0.7) that is\n not part of a preset's declared identity: --glass-wash-l is the theme COLOUR (moving it changes\n what lapis IS), and --glass-tint-a is already per-mode and desaturates. Measured across all 17\n scopes, L92 is what puts every preset's body text over the 75 floor with the 1.5 Lc margin, with\n no preset's tint tokens touched. DARK needs none of this (it clears everywhere already), so it\n pins both back to the opaque floor below and nothing changes there. */\n --glass-solidify-l: 92;\n /* The floor's chroma ceiling collapses as it lightens, so the L88 caps above would clip here. Rather\n than 17 new per-preset numbers, derive from the ones each preset already declares: across the\n palette the L92/L88 ceiling ratio runs 0.60–0.90, and 0.65 lands every scope INSIDE its real\n ceiling (worst 0.99×, verified by scripts/check-gamut.mjs). Hues that peak light — peridot,\n turquoise, aventurine, amber, aquamarine — are not bound by it at all and keep 100% of their\n surface chroma; the dark-peaking arc keeps 80–86%. */\n --glass-solidify-c-max: calc(var(--glass-opaque-c-max, 0.055) * 0.65);\n /* ── Chakra — the step-cut material (between opaque and crystal) ─────────────\n Every other material is lit from ONE direction: --glass-shadow carries a bright top bevel and a\n dark bottom shade, so surfaces read as a lit dome. Chakra lights all FOUR edges as mitered facets\n (a conic-gradient with hard stops on the diagonals, composed in engine.css), so it reads as a\n step-cut gem instead.\n The facets are pure LUMINANCE overlays — --shadow-highlight / --shadow-ink at these alphas, chroma\n 0 — not tinted colors. Two reasons, and they are the same reason: (1) the theme hue already comes\n through from the tinted body floor beneath, and a translucent white/black over it shifts lightness\n while preserving hue; (2) a bright facet sits near L96 in light mode, where the sRGB chroma ceiling\n across the 17 presets collapses to 0.0182 — a tinted highlight there would be gamut-mapped by the\n browser at nearly every preset. This is the theme's existing rule for the top bevel (\"a specular,\n not a shadow; grey speculars read as grime\"), applied to all four sides.\n The alphas are irregular mode TWINS, not one knob: the same alpha buys a very different lightness\n step over an L88 body than over an L28 one. They are solved so BOTH modes span 18 L end to end —\n light 94.0 > 90.5 > 88.0 > 83.0 > 76.0, dark 38.0 > 32.0 > 28.0 > 24.0 > 20.0 (top, left, table,\n right, bottom). Light comes from the top-left, matching the 135° axis of --glass-bg. */\n --glass-chakra-l: 88; /* body/table lightness */\n /* Body alpha — the \"between crystal and opaque\" dial, and now genuinely that across the WHOLE\n surface: crystal's floor sits at 0.3, opaque at 1. Nothing is painted over the middle any more\n (the cut moved into box-shadow), so this alone decides how much backdrop shows, and glass-diffuse\n / --glass-opacity modulate it the same way they do on crystal. */\n --glass-chakra-a: 0.62;\n --glass-chakra-c-max: 0.055; /* body chroma cap — ≤ 0.0564, the L-88 ceiling floor (lapis h268) */\n --glass-chakra-step: 3px; /* width of ONE bevel step (the frame is two) */\n --glass-chakra-facet-t-a: 0.5; /* top — highlight, +6.0 L */\n --glass-chakra-facet-l-a: 0.208; /* left — highlight, +2.5 L */\n --glass-chakra-facet-r-a: 0.057; /* right — ink, −5.0 L */\n --glass-chakra-facet-b-a: 0.136; /* bottom — ink, −12.0 L */\n /* ── Glass shadows — mode TWINS (not knobs: the light↔dark alpha ratios AND layer structures are\n irregular). They compose only --shadow-ink/-highlight, never tint vars, so bare placement is legal.\n LIGHT twins carry extra definition layers dark mode gets for free (a light surface on a dark page\n separates itself): a tight CONTACT drop + 1px dark outer HAIRLINE define the edge on same-lightness\n backdrops, and a directional BEVEL (bright top inner edge, ink bottom inner edge) makes pills read\n convex instead of printed. The last layer flips family per mode: dark's white inner GLOW (luminous\n glass) becomes an ink VIGNETTE in light — a white wash on a light surface brightens without\n defining, while a faint vignette reads as glass thickness. (Frosted keeps its white glow in both\n modes: milkiness IS that material.) The top bevel stays WHITE in both — it's a specular, not a\n shadow; grey speculars read as grime. Layer order: ambient drop, contact drop, hairline, top\n bevel, bottom shade, inner glow/vignette. */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.13), 0 1px 3px oklch(var(--shadow-ink) / 0.11), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.4) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.08) inset,\n 0 0 12px oklch(var(--shadow-ink) / 0.05) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.16), 0 2px 6px oklch(var(--shadow-ink) / 0.12), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.45) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-ink) / 0.06) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.1), 0 1px 2px oklch(var(--shadow-ink) / 0.09), 0 0 0 1px oklch(var(--shadow-ink) / 0.04),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.35) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.07) inset,\n 0 0 8px oklch(var(--shadow-ink) / 0.04) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.3);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.17), 0 2px 6px oklch(var(--shadow-ink) / 0.13), 0 0 0 1px oklch(var(--shadow-ink) / 0.05),\n 0 1px 0 oklch(var(--shadow-highlight) / 0.5) inset, 0 -1px 0 oklch(var(--shadow-ink) / 0.09) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.4) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.45) inset;\n /* Base colors - Light mode with dark text */\n --background: transparent;\n --foreground: oklch(\n 0.2 0.035 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(\n 0.45 0.077 var(--glass-fg-h)\n ); /* large/heading — baseline matched to AutoForeground's median pick; chroma is the LOWEST any preset hue solves to, so no hue clips before JS refines */\n --foreground-strong: oklch(\n 0.2 0.034 var(--glass-fg-h)\n ); /* fine/small — the ramp's tonal clip put this at L20, not L12; the old value flashed dark-then-tinted on every load */\n --foreground-ui: oklch(0.42 0.071 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static baselines for the OPAQUE + CRYSTAL tier sets (the [data-material] remaps read these) —\n WITHOUT them, a no-JS consumer (or SSR first paint) would substitute an unset var and INHERIT the\n page foreground onto those floors. Light mode: the opaque floor (L90) and crystal surface share\n the page's direction, so the normal literals apply; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.14 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.14 0.04 var(--glass-fg-h));\n /* Chakra tier baselines — text sits on the TABLE (the flat centre, L 88), not on the facets, so in\n light mode the direction matches the page and the normal literals apply. AutoForeground re-bands\n against the composited table when mounted. */\n --foreground-chakra: oklch(0.22 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.4 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.12 0.04 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.46 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.14 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors - Light mode */\n --primary-l: 0.42; /* primary ink lightness (dark pill on light pages) — the color itself is composed in the engine, following the theme hue */\n /* Ceiling on the primary ink's chroma. The ×1.2 in the engine is intent — primary should read MORE\n saturated than the base tint — but uncapped it asked past the sRGB edge for five presets in light\n (turquoise, aquamarine, success, warning, aurora) and two in dark (lapis, info), where the browser\n gamut-maps and the delivered colour stops tracking the authored one.\n Measured against --glass-fg-h, not --glass-tint-h: --primary follows the FOREGROUND hue, and the\n Muse/Gloaming frescoes decouple the two (fg 290 / 18), so those hues are in the sweep — aurora only\n shows up as out-of-gamut once you check the fg hue. */\n --primary-c-max: 0.069; /* ≤ 0.0697, the L-42 ceiling floor — h205 (aurora) is now tighter than h190 */\n --primary-foreground: oklch(0.98 0 0); /* White text on the primary ink — Lc 88.1 at the worst hue, turquoise (measured) */\n\n /* Secondary colors - Light mode */\n /* ── Flat-neutral shadcn SURFACES, now theme-tinted ──────────────────────────\n --accent, --secondary, --muted and --sidebar were the last chroma-0 tokens in the theme, which is\n why a toggle's on-state read as near-white in light and near-black in dark no matter which preset\n was active: the fill simply had no hue to give. Same treatment as --border — lightness stays put\n (pure recolor), the colour moves to engine.css so it re-resolves per tint scope, and chroma is\n min(the preset's own, a cap) so a preset that declares less keeps its restraint and selenite stays\n exactly neutral.\n Each cap is measured against the tightest sRGB ceiling across all 17 presets AT THAT LIGHTNESS, and\n they differ by a factor of eight because the ceiling collapses toward white: 0.0714 at L 85, but\n only 0.0091 at L 98. One shared cap would have to be the smallest, tinting nothing. */\n --secondary-l: 0.96;\n --secondary-c-max: 0.018; /* ≤ 0.0182, the L-96 ceiling floor (lapis h268) */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors - Light mode */\n --muted-l: 0.85;\n --muted-c-max: 0.071; /* ≤ 0.0714, the L-85 ceiling floor (lapis h268) */\n --muted-foreground: oklch(\n 0.35 0.06 var(--glass-fg-h)\n ); /* Level 1 — L35, not L14: the widest gap of the set, and the one doing most of the first-paint flash */\n\n /* Accent colors - Light mode. NOTE the knob prefix: --accent-c / --accent-h are already taken as the\n user-facing overrides for --glass-accent (the vivid switch/slider fill), a different role. */\n /* L93, down from 0.96, and the cap raised with it. The hover fill was reading as plain white: at L96 the\n sRGB gamut allows a chroma of only 0.0182, an RGB spread of 19, so \"tinted\" was arithmetically\n invisible. Chroma near the extremes is not a free parameter — it is whatever the gamut leaves — so the\n only way to a visible tint is to step off the extreme. Three points of lightness buys 1.7x the spread\n (19 -> 32) and moves the fill DARKER than the surface, the direction a highlight should go on a light\n page. It cannot go much further: --accent is also painted at 50% (bg-accent/50 on calendar's \"today\",\n button's active state, ToggleGroup hover), and that composite over an L88 card is what binds — at L92\n it drops to 76.0 Lc, under the floor+margin, while L93 holds 76.9. */\n --accent-bg-l: 0.93;\n --accent-bg-c-max: 0.032; /* ≤ 0.0323, the L-93 ceiling floor (lapis h268) */\n /* Stays --foreground: this token is text on a SURFACE-lightness background, and it has two callers with\n that shape — the accent fill here, and the bg-foreground/10 wash the menus use (dropdown, context,\n navigation, command, menubar, select). They only share a text colour while --accent stays near the\n surface, which is precisely why the fill was moved by chroma rather than by weight. */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.55 0.22 25); /* Red - 4.5:1 with white */\n --destructive-foreground: oklch(0.98 0 0); /* White text on red */\n\n /* Border / input LIGHTNESS + chroma-CAP knobs — the COLOR is composed in engine.css so the edge\n follows the theme tint like every other surface token (leaves, so they inherit into tint scopes\n where the engine re-resolves).\n L 0.85, not the 0.88 the flat neutral carried: sRGB's chroma ceiling collapses as lightness climbs,\n and at 0.88 the binding ceiling across all 17 presets is 0.0564 — the most colorful in-gamut edge\n possible there still reads as grey (mean RGB spread 51). Dropping 3 points of L raises the ceiling\n to 0.0714 and nearly doubles delivered colorfulness (mean spread 65) while moving the grey\n reference only #d7d7d7 → #cecece, which is below the threshold of a perceived weight change.\n --border-c-max is the CAP, not the value: chroma is min(tint chroma, cap), so a preset that\n declares less than the cap keeps its own restraint — selenite (c 0) stays exactly neutral, which is\n the whole point of that theme — while lapis (c 0.15) clamps instead of being gamut-mapped by the\n browser. A flat multiplier cannot do both: any factor small enough to keep lapis in gamut drags the\n 0.07 jewels below where they are today. */\n --border-l: 0.85;\n --input-l: 0.85;\n --border-c-max: 0.07; /* ≤ 0.0714, the L-0.85 ceiling floor (lapis h268) */\n --ring: oklch(0.5 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.6 0.2 250); /* Blue */\n --chart-2: oklch(0.55 0.15 150); /* Green */\n --chart-3: oklch(0.5 0.2 50); /* Yellow */\n --chart-4: oklch(0.65 0.2 300); /* Purple */\n --chart-5: oklch(0.6 0.2 20); /* Orange */\n\n /* Sidebar colors - Light mode */\n --sidebar-l: 0.98;\n --sidebar-c-max: 0.009; /* ≤ 0.0091, the L-98 ceiling floor — near white holds almost no hue */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.5 0.2 250); /* Blue */\n --sidebar-primary-foreground: oklch(0.98 0 0); /* White text on primary */\n --sidebar-accent: oklch(0.15 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.5 0.2 250); /* Blue ring - 3:1 contrast */\n}\n\n.dark {\n /* Base colors — light text on a dark ground */\n --background: transparent;\n /* Pressed/active shadow SCALE — dark overrides: primaries deepen +0.1, contact layers +0.05, so the\n push-in still reads on dark surfaces (see :root for the light ladder + rationale). */\n --press-shadow-sm: inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3);\n --press-shadow-strong: inset 0 2px 4px oklch(var(--shadow-ink) / 0.3), inset 0 1px 2px oklch(var(--shadow-ink) / 0.2);\n --press-shadow-deep: inset 0 2px 4px oklch(var(--shadow-ink) / 0.35), inset 0 1px 2px oklch(var(--shadow-ink) / 0.25);\n --active-shadow:\n 0 2px 6px oklch(var(--shadow-ink) / 0.4), inset 0 1px 2px oklch(var(--shadow-highlight) / 0.15),\n inset 0 0 8px oklch(var(--shadow-highlight) / 0.1);\n /* Selected-control fill — dark twin. Deliberately UNCHANGED in behaviour from the `bg-white/20` it\n replaces: chroma cap 0 makes min() resolve to 0, so this is a pure white lift and the hue arrives\n by show-through from the surface below, which is exactly why dark already read correctly. Light\n has to source its hue directly (see :root); the two modes reach the same result by different\n routes because the near-white gamut only permits one of them. */\n --active-l: 100%;\n --active-c-max: 0;\n --active-a: 0.2;\n /* ── Mode knobs — dark twins (see :root for what each dial does). */\n --glass-sheet-a: 0.05;\n --glass-sheet-a1: 0.03;\n --glass-frost-boost: 0.06;\n --glass-border-a: 0.15;\n --glass-wash-l: 58%;\n --glass-accent-l: 0.68;\n --glass-accent-c: 0.14;\n --glass-glow-lc: 0.7 0.18;\n --glass-glow-a: 0.5;\n --harmonic-l: 0.68;\n --mono-l-1: 0.82;\n --mono-l-2: 0.68;\n --mono-l-3: 0.5;\n --gradient-l: 0.68;\n --gradient-c: 0.14;\n --gradient-contrast-l: 0.9;\n --gradient-contrast-c: 0.09;\n --glass-crystal-l: 96; /* shared with light: the ceiling that forces 96 is hue-driven, not mode-driven */\n --glass-crystal-bg-a: 0.1;\n /* Gloss ink lightness — dark twin. Over an L-28 floor there is 72 L of headroom, so 66 lifts +15.2\n and still holds 59% of the tonal ink's chroma: a specular that is genuinely tinted. Light cannot\n have both (see :root). */\n --glass-gloss-l: 66;\n /* Gloss chroma cap — the L-66 twin. Far more headroom this far from white: 0.1094 is aurora (h205),\n the tightest across all 23 preset scopes — the FRESCOES count here too, which is why this is not\n turquoise's 0.1106. Dark gloss keeps real colour; it was still clipping before (turquoise\n 0,193,186 and carnelian 255,0,0 both had a pinned channel). */\n --glass-gloss-c-max: 0.109;\n /* L lifted from 22% (less extreme light↔dark switch); the opaque chroma scale (0.9) keeps warm hues\n like amber in-gamut instead of hard-clipping to rust. */\n --glass-solid-l: 18%;\n /* The retired sheet's compensation, dark twin (see :root). Much the larger move of the two modes: a\n near-white sheet over a DARK floor lifts it ~12 codes where over the light floor it lifts ~2–5. Same\n two-knob fit, worst OKLab dE 0.0045 across the 14 presets. */\n --glass-opaque-l: 36.4;\n /* 1.05, up from 0.85. The dark floor was never cap-limited — every preset sat at 31–79% of its own\n ceiling, so the MULTIPLIER was the throttle and dark opaque cards lost their tint. 1.05 is the most\n the tightest hue (turquoise) can take without clipping; it lifts every preset +24% at Lc 80.0. */\n --glass-opaque-c-scale: 1.05;\n /* Opaque floor chroma cap, dark twin (see :root). A GUARD, not a throttle — the distinction matters\n and this value used to get it wrong. It was 0.06, the tightest ceiling across the preset scopes,\n which was correct while every jewel shared --glass-tint-c: 0.07 and the floor multiplied it blind.\n Once each preset tuned its own chroma to its own hue, that shared floor-of-the-tightest started\n costing the hues WITH headroom: at 0.06 amethyst lost 30% of its ask, tourmaline 33%, rose 26% —\n against own ceilings of 0.189, 0.152 and 0.142. Measured, every preset's dark ask now sits at\n 0.31–0.79× of its own ceiling, so nothing clips with no cap at all. 0.095 clears the largest ask\n (tourmaline 0.0901) and therefore throttles nobody, while still bounding a future preset that\n pushes chroma far past today's values. check-gamut scores each preset against its own ceiling. */\n /* Guard, not a throttle: the largest dark ask is tourmaline at 0.111, so this binds nobody. */\n --glass-opaque-c-max: 0.12;\n /* Dark keeps the solidify floor IDENTICAL to the opaque floor — the split exists to buy LIGHT-mode\n text contrast, and dark already clears the body floor on every preset with margin. Pinning to the\n opaque values (rather than leaving :root's L92 / ×0.65 to inherit) is what makes this a light-only\n change: a lighter backing under dark glass would wash the whole mode out. */\n --glass-solidify-l: var(--glass-opaque-l);\n --glass-solidify-c-max: var(--glass-opaque-c-max);\n /* ── Chakra dark twins (see :root for what each dial does + how the alphas were solved).\n The facet alphas invert in character: over an L28 body there is 72 L of headroom UP and only 28\n DOWN, so the highlights need far less alpha and the inks far more than their light-mode twins to\n land the same 18 L span. The targets also skew brighter here (+10/−8 rather than +6/−12) — a dark\n gem reads as cut by catching light, where a light one reads by casting shade. */\n --glass-chakra-l: 28;\n --glass-chakra-a: 0.58;\n --glass-chakra-c-max: 0.046; /* ≤ 0.0471, the L-28 ceiling floor (turquoise h190) */\n --glass-chakra-facet-t-a: 0.139; /* top — highlight, +10.0 L */\n --glass-chakra-facet-l-a: 0.056; /* left — highlight, +4.0 L */\n --glass-chakra-facet-r-a: 0.143; /* right — ink, −4.0 L */\n --glass-chakra-facet-b-a: 0.286; /* bottom — ink, −8.0 L */\n /* ── Glass shadows — dark twins (deeper drops, brighter bevels). */\n --glass-shadow:\n 0 4px 16px oklch(var(--shadow-ink) / 0.3), 0 1px 4px oklch(var(--shadow-ink) / 0.15), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.12) inset,\n 0 0 12px oklch(var(--shadow-highlight) / 0.15) inset;\n --glass-shadow-lg:\n 0 8px 32px oklch(var(--shadow-ink) / 0.4), 0 2px 8px oklch(var(--shadow-ink) / 0.2), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.15) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.18) inset;\n --glass-shadow-sm:\n 0 2px 8px oklch(var(--shadow-ink) / 0.25), 0 1px 2px oklch(var(--shadow-ink) / 0.12), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.1) inset,\n 0 0 8px oklch(var(--shadow-highlight) / 0.12) inset;\n --glass-frosted-border: oklch(100% 0 0 / 0.25);\n --glass-frosted-shadow:\n 0 8px 32px oklch(var(--shadow-ink) / 0.5), 0 2px 8px oklch(var(--shadow-ink) / 0.3), 0 0 0 0.5px oklch(var(--shadow-highlight) / 0.2) inset,\n 0 0 20px oklch(var(--shadow-highlight) / 0.22) inset;\n --glass-crystal-shadow: var(--glass-shadow), 0 1px 1px oklch(var(--shadow-highlight) / 0.2) inset;\n --glass-crystal-shadow-hover: var(--glass-shadow-lg), 0 1px 1px oklch(var(--shadow-highlight) / 0.25) inset;\n --foreground: oklch(\n 0.95 0.03 var(--glass-fg-h)\n ); /* CSS baseline (tinted, ~body target) so first paint has no gray→tint flash; AutoForeground refines to the exact APCA solve after */\n /* Size-tiered foregrounds (ARC Bronze Simple Mode bands) — static fallback; AutoForeground re-bands\n these against the live glass surface at runtime. text-foreground-soft / -strong utilities. */\n --foreground-soft: oklch(0.83 0.065 var(--glass-fg-h)); /* large/heading — refined at runtime */\n --foreground-strong: oklch(0.97 0.015 var(--glass-fg-h)); /* fine/small — refined at runtime */\n --foreground-ui: oklch(0.87 0.057 var(--glass-fg-h)); /* icons — AutoForeground re-bands (+ optional hue) */\n /* Static OPAQUE + CRYSTAL tier baselines, dark twins — the standard dark opaque floor (L32) and\n crystal surface keep the page's light-text direction. Presets whose dark floors flip LIGHT (moonstone\n cream) override the -opaque set in their own block; AutoForeground refines when mounted. */\n --foreground-opaque: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-opaque: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-opaque: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-opaque: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-opaque: oklch(0.89 0.04 var(--glass-fg-h));\n --foreground-crystal: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-crystal: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-crystal: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-crystal: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-crystal: oklch(0.89 0.04 var(--glass-fg-h));\n /* Chakra tier baselines, dark twins — the L-28 table keeps the page's light-text direction. */\n --foreground-chakra: oklch(0.92 0.04 var(--glass-fg-h));\n --foreground-soft-chakra: oklch(0.78 0.04 var(--glass-fg-h));\n --foreground-strong-chakra: oklch(0.96 0.035 var(--glass-fg-h));\n --foreground-ui-chakra: oklch(0.75 0.05 var(--glass-fg-h));\n --muted-foreground-chakra: oklch(0.89 0.04 var(--glass-fg-h));\n --card: transparent;\n --card-foreground: var(--foreground);\n --popover: transparent;\n --popover-foreground: var(--foreground);\n\n /* Primary colors — dark mode */\n --primary-l: 0.78; /* primary ink lightness in dark mode (light pill) — color composed in the engine */\n --primary-c-max: 0.107; /* ≤ 0.1077, the L-78 ceiling floor (h268 lapis) */\n /* Near-black text on the primary ink — Lc 62.9 at the worst hue, sistine (measured). It was 60.9\n before --primary-c-max: capping the chroma the browser used to gamut-map moved the worst case back\n above the 62 this comment had been claiming. */\n --primary-foreground: oklch(0.15 0 0);\n\n /* Secondary colors — dark mode */\n /* Flat-neutral surfaces — dark twins (see :root for why these are knobs now). Lightness unchanged;\n the caps are the dark ceilings, which are tightest at the DARKEST step, not the lightest. */\n --secondary-l: 0.25;\n --secondary-c-max: 0.041; /* ≤ 0.0417, the L-25 ceiling floor — h205 (aurora) is now tighter than h190 */\n --secondary-foreground: var(--foreground);\n\n /* Muted colors — dark mode */\n --muted-l: 0.18;\n --muted-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --muted-foreground: oklch(0.9 0.031 var(--glass-fg-h)); /* Level 1 — one ramp step toward the chosen color */\n\n /* Accent colors — dark mode */\n /* L34, up from 0.25 — the same fix as light, mirrored. At L25 the ceiling was 0.0417 (spread 29), so the\n fill read as near-black; L34 allows 0.0565 (spread 42) and moves it BRIGHTER than the surface, which is\n the direction a highlight goes on a dark page. Dark has more room than light here (its 50% composite\n sits at 77.5 Lc rather than 76.0), because the fill and the L36.4 card floor are close together. */\n --accent-bg-l: 0.34;\n --accent-bg-c-max: 0.056; /* ≤ 0.0565, the L-34 ceiling floor (aurora h205) */\n --accent-foreground: var(--foreground);\n\n /* Destructive colors - WCAG AA compliant (4.5:1 contrast) */\n --destructive: oklch(0.65 0.22 25); /* Light red - 4.5:1 with dark background */\n --destructive-foreground: oklch(1 0 0); /* White text on red — APCA Lc ~67 (near-black was Lc 42) */\n\n /* Border / input lightness + chroma-cap — dark twins (see :root; color composed in engine.css).\n L is UNCHANGED here: in dark mode the edge sits at 0.30/0.25 on a dark page, so lowering it would\n cost contrast rather than buy color. Only the cap moves, up to the tightest dark ceiling — 0.0422\n at L 0.25 (turquoise h190), which --input hits first, so one cap serves both. */\n --border-l: 0.3;\n --input-l: 0.25;\n --border-c-max: 0.041; /* ≤ 0.0422, the L-0.25 ceiling floor (turquoise h190) */\n --ring: oklch(0.6 0.2 250); /* Focus ring */\n\n /* Chart colors - WCAG AA compliant */\n --chart-1: oklch(0.65 0.2 250); /* Light blue */\n --chart-2: oklch(0.7 0.15 150); /* Light green */\n --chart-3: oklch(0.75 0.2 50); /* Light yellow */\n --chart-4: oklch(0.7 0.2 300); /* Light purple */\n --chart-5: oklch(0.68 0.2 20); /* Light orange */\n\n /* Sidebar colors — dark mode */\n --sidebar-l: 0.18;\n --sidebar-c-max: 0.03; /* ≤ 0.0307, the L-18 ceiling floor (turquoise h190) */\n --sidebar-foreground: var(--foreground);\n --sidebar-primary: oklch(0.56 0.16 250); /* Match --primary; white text clears APCA */\n --sidebar-primary-foreground: oklch(1 0 0); /* White on the sidebar's brand fill */\n --sidebar-accent: oklch(1 0 0 / 0.1); /* Subtle glass overlay (≈ foreground/10), not a solid block */\n --sidebar-accent-foreground: var(--foreground);\n --sidebar-border: var(--border); /* Same divider role, same value it already carried — follow the role token */\n --sidebar-ring: oklch(0.6 0.2 250); /* Light blue ring - 3:1 contrast */\n}\n\n@layer base {\n * {\n @apply border-border outline-ring/50;\n scrollbar-width: thin;\n scrollbar-color: color-mix(in oklch, var(--foreground) 22%, transparent) transparent;\n }\n body {\n /* Text uses the band-picked --foreground (set on by AutoForeground on theme/tint change;\n globals carries a static fallback so there's no flash). */\n color: var(--foreground);\n background-color: transparent;\n }\n /* Thin, glass-friendly scrollbars (replaces the chunky native bar) */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: color-mix(in oklch, var(--foreground) 22%, transparent);\n border-radius: 9999px;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in oklch, var(--foreground) 38%, transparent);\n }\n}\n\n/* ── Below the floor ──────────────────────────────────────────────────────────\n package.json's browserslist is the supported floor (Safari 17.5 / Chrome 120 / Firefox 128 / Edge\n 120). It is set by what this theme actually uses, not by preference — the binding constraints are\n text-wrap:balance (Safari 17.5), bare-`&` CSS nesting and unprefixed mask-composite (Chrome 120),\n and @property (Firefox 128). Tailwind v4's own baseline sits just under it.\n\n Every colour in this theme is oklch(), fed through custom properties. On an engine without oklch the\n custom property still PARSES (custom props accept anything) and only fails when substituted — so\n `background-color: var(--color-background)` resolves to an invalid value and computes to `unset`,\n i.e. transparent, rather than to a fallback. Surfaces disappear; text usually survives on the UA\n default. This is not a polyfill and does not try to reproduce the palette — it guarantees a legible\n page instead of transparent panels on stacked backgrounds.\n\n UNVERIFIED IN A REAL ENGINE: no browser in the test matrix lacks oklch, so this branch has only been\n confirmed to compile, not to render. Treat it as a floor, not a tested path. */\n@supports not (color: oklch(0% 0 0)) {\n :root {\n color-scheme: light;\n background-color: #f7f7fb;\n color: #17161c;\n }\n body {\n background-color: #f7f7fb;\n color: #17161c;\n }\n .glass,\n [data-material] {\n background-color: #ffffffe6;\n border: 1px solid #17161c26;\n backdrop-filter: none;\n }\n}\n/* ── Glass engine ─────────────────────────────────────────────────────────────\n TWO grouped blocks, split by what must re-resolve where (the var-composition-resolves-at-declaration\n gotcha — see scripts/check-theme.mjs):\n\n A. TINT ANCHORS (:root, [data-glass-tint], …) — the tint knobs plus everything mode-INVARIANT that\n composes them, declared on :root AND every tint scope so a scoped data-glass-tint re-resolves the\n whole subgraph. Deliberately NOT on `.dark`: re-declaring --glass-tint-h / --glass-fg-h at a bare\n `.dark` wrapper would reset a page tint inherited from above.\n\n B. MODE-DERIVED SURFACES (:root, .dark, [data-glass-tint], …) — every tint-composing token whose\n light/dark difference is a handful of numbers. Each is declared ONCE, composing the mode knobs\n (--glass-sheet-a, --glass-wash-l, … — see :root/.dark) which are LEAVES that inherit into tint\n scopes. `.dark` IS in this selector so a dark wrapper inside a light page re-composes its surfaces\n from the wrapper's own dark knobs (same-element cascade) — \"toggle .dark anywhere\" keeps working.\n\n Foreground tokens stay on bare :root/.dark (AutoForeground owns them). Preset blocks below override\n --glass-tint-h/c/a by source order. Shadow composites are mode-TWINNED on bare :root/.dark (their\n light↔dark ratios are irregular — knobbing them would change values). */\n:root,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Deliberately restrained glassmorphism — enough to read as glass, never enough to fog what is behind it. */\n /* Glass tint — recolor ALL glass by changing these; presets set them via [data-glass-tint].\n -h = hue, -c = chroma (gradient), -a = wash alpha. The gradient alone is too sheer\n (~6–12% opacity) to show a hue, so -a paints a solid colored floor (--glass-tint-wash)\n that reads over any backdrop. Neutral keeps -a:0, so the default glass is unchanged. */\n --glass-tint-h: 250;\n --glass-tint-c: 0.018;\n --glass-tint-a: 0;\n /* The same chroma, capped for NEAR-WHITE surfaces. The sheet stops, the borders and the crystal\n floor sit at L 95–100%, where sRGB's chroma ceiling collapses AND becomes strongly hue-dependent:\n at L=97% it is ~0.071 for peridot (h128) but ~0.014 for lapis (h268). Feeding those the raw\n --glass-tint-c means each preset lands on whatever fraction of its declared value its hue happens\n to permit — lapis was delivering 9% of its 0.15 where peridot delivered 98% of its 0.07 — and the\n shortfall is then resolved by the browser's gamut mapping rather than by us. That is the same\n class of bug the 3% margin in lib/oklch-utils guards against on the computed path (\"foregrounds\n went grey in Safari while staying tinted in Chrome\"); this is its CSS-side counterpart.\n Capping the BASE (rather than lowering any preset) keeps chroma where it has headroom — a jewel's\n depth lives at mid lightness, in --primary and --glass-tint-wash, which are untouched by this.\n Guarded by scripts/check-gamut.mjs.\n 0.025 was still too generous, because the sheet MULTIPLIES this cap: its stops are\n calc(--glass-tint-c-hi * 0.3…1.3), so the top stop asked 0.0325 against an L-95 ceiling of 0.0229\n at the tightest hue — the cap itself clipped. 0.017 is that ceiling divided by the 1.3 multiplier,\n so the sheet now lands inside the gamut at every preset hue instead of at whatever the browser\n chops it to. Cap-then-multiply is the order that matters here; see check-gamut's SCALE/CAP/BOTH\n shapes, which model each form separately for exactly this reason. */\n --glass-tint-c-hi: min(var(--glass-tint-c), 0.017);\n /* Foreground hue — drives every FOREGROUND-keyed token (body/heading text via AutoForeground,\n --muted-foreground, --glass-accent, --glass-glow, --gradient*), kept SEPARATE from the surface\n hue so tuning text never shifts the glass/fresco surface. Defaults to the tint hue (jewels: text\n == surface); frescoes override it to anchor text off their multi-hue surface (Muse night: surface\n 222, text 310). AutoForeground reads this, falling back to --glass-tint-h for older consumers. */\n --glass-fg-h: var(--glass-tint-h);\n /* ── Harmonic color system ────────────────────────────────────────────────────────────────────\n Color-wheel relationships derived from the CONTENT hue (--glass-fg-h) — the same hue --glass-accent\n and --gradient track — so they rotate with the theme/tint automatically (a generalization of the\n --gradient's +63.53° offset). Hues are mode-invariant ANGLES; CSS Color 4 wraps them past 360°, so use\n anywhere as oklch(L C var(--hue-*)); choosing your own L/C also keeps them in-gamut. The ready --color-*\n tokens sit at the --glass-accent envelope (0.6/0.15; .dark lifts them). NOTE: a fixed-chroma harmonic can\n land at the sRGB edge at some hues and get browser-gamut-mapped — prefer the hue tokens + a conservative\n chroma when that matters. */\n /* Harmonic anchor hue — the wheel origin the harmonies rotate from. Normally the content hue, but the two\n \"hue-less\" themes (selenite + moonstone) anchor at 0° instead — set by the tint switcher (selenite) + moonstone's\n block below — so their accents come out a colorful red-based harmony, not a warp of a near-grey hue. */\n --harmony-h: var(--glass-fg-h);\n --hue-base: var(--harmony-h);\n --hue-complement: calc(var(--harmony-h) + 180);\n --hue-analogous-1: calc(var(--harmony-h) - 30);\n --hue-analogous-2: calc(var(--harmony-h) + 30);\n --hue-split-1: calc(var(--harmony-h) + 150);\n --hue-split-2: calc(var(--harmony-h) + 210);\n --hue-triad-1: calc(var(--harmony-h) + 120);\n --hue-triad-2: calc(var(--harmony-h) + 240);\n --hue-tetrad-1: calc(var(--harmony-h) + 60); /* rectangle: base, +60, +180, +240 */\n --hue-tetrad-2: calc(var(--harmony-h) + 180);\n --hue-tetrad-3: calc(var(--harmony-h) + 240);\n --hue-square-1: calc(var(--harmony-h) + 90); /* square: base, +90, +180, +270 */\n --hue-square-2: calc(var(--harmony-h) + 180);\n --hue-square-3: calc(var(--harmony-h) + 270);\n /* Harmonic chroma — a fixed vivid accent chroma so the palette stays colorful in EVERY theme (selenite +\n moonstone included; they get their color from the hue-0 anchor above, not from chroma). --mono-chroma is\n quieter (0.6×) for the subtle mono ramp. */\n --color-chroma: 0.15;\n --mono-chroma: calc(var(--color-chroma) * 0.6);\n /* (--color-* / --mono-* ready colors live in the mode-derived surfaces block below — their L is the\n mode envelope; the hue angles above are the mode-invariant part.) */\n /* (The solidify floor — the --glass-opacity \"component opacity\" dial — is composed INSIDE each sheer\n glass utility, at the element. Composing it here would bake :root's --glass-opacity into the value\n and silently ignore a per-element glass={{opacity}} — the var-composition gotcha again.) */\n /* Blur values — moderate by design; per-variant sizes (mode-invariant). */\n --blur: 2px;\n --blur-sm: 1px;\n --blur-lg: 8px;\n --blur-xl: 12px;\n --blur-frosted: 25px;\n /* Chakra reads as a THICK stone, so it diffuses more than crystal's near-clear 2px even though its\n body is more solid — the two axes are independent (how much light passes vs how scattered it is). */\n --blur-chakra: 6px;\n /* Crystal edge — identical in both modes, so it lives here with the anchors (the crystal floor +\n shadows are mode-varying: --glass-crystal-bg in the surfaces block below, shadows on :root/.dark). */\n --glass-crystal-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / 0.3);\n /* Crystal gloss — the 3-part light effect (top highlight + diagonal streak + bottom glow) baked into\n --crystal-stack-bg (the crystal material + the [data-glass=\"crystal\"] page style) and layered by\n glass-sheen. Each is a full gradient, so it composes in a background-image list AND in pseudo layers.\n THREE FLAVORS, selected by data-gloss on any ancestor (default, unset = tonal — see the [data-gloss]\n blocks further down):\n white → flat white specular (the classic glass shine)\n tonal → a tonally-close single-hue tint of the theme (--glass-gloss-l / --glass-gloss-tint)\n hue → iridescent: the highlight sweeps hues shifted ± --glass-gloss-hue-span around the tint hue\n White/tonal share the gradient SHAPES below (only --glass-gloss-ink, the L C H triple, differs); hue\n redefines the three gradients to interpolate across hue. Each layer applies its own alpha. */\n /* --glass-gloss-l / --glass-gloss-tint live in tokens.css (66 / 4.25) — do NOT re-declare here:\n engine.css imports AFTER tokens.css, so a duplicate at equal :root specificity silently wins and\n pure-CSS consumers get a stale gloss. */\n --glass-gloss-hue-span: 40; /* hue: degrees the iridescent sweep shifts up & down from the tint hue */\n /* hue: chroma of the iridescent stops, at the flavor's own L 74. Was 0.16, chosen believing an\n over-ceiling request is gamut-MAPPED down to the ceiling (\"holds 81% of that chroma\"). WebKit clips\n per channel instead (see --glass-gloss-c-max in tokens.css), so 0.16 pinned a channel on the\n tightest hues rather than delivering 81% of itself. 0.12 sits just under 0.1226 — aurora (h205) at\n L 74, the tightest across all 23 preset scopes (frescoes included) — so every hue in the sweep now\n renders what it asks for. */\n --glass-gloss-c: 0.12;\n --glass-gloss-hue-dir: 1; /* gloss direction (±1) — flips the TONAL streak diagonal (calc angle) + the HUE sweep order (span × dir); toggled in the crystal demo */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n --glass-gloss-top: linear-gradient(to bottom, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.4), transparent 30%);\n --glass-gloss-streak: linear-gradient(\n calc(135deg * var(--glass-gloss-hue-dir, 1)),\n oklch(var(--glass-gloss-ink, 100% 0 0) / 0.15),\n transparent 50%\n );\n --glass-gloss-glow: radial-gradient(circle at 50% 120%, oklch(var(--glass-gloss-ink, 100% 0 0) / 0.2), transparent 70%);\n --blur-crystal: 2px;\n}\n\n/* B. MODE-DERIVED SURFACES — see the engine header above. Each tint-composing token is declared ONCE,\n parameterized by the mode knobs on bare :root/.dark; `.dark` in this selector keeps dark wrappers\n re-composing with their own knobs. */\n:root,\n.dark,\n[data-glass-tint],\n[data-sonner-toast][data-type] {\n /* Solid colored floor under the sheer gradient (see --glass-tint-a) — mode via --glass-wash-l/-c-mult. */\n --glass-tint-wash: oklch(var(--glass-wash-l) calc(var(--glass-tint-c) * var(--glass-wash-c-mult)) var(--glass-tint-h) / var(--glass-tint-a));\n /* Solid accent that tracks the FOREGROUND hue at a fixed vivid chroma — used by active controls\n (switch \"on\", slider range). The --accent-h/-c knob overrides apply in BOTH modes (the old dark\n twin dropped them — that was a bug). */\n --glass-accent: oklch(var(--glass-accent-l) var(--accent-c, var(--glass-accent-c)) var(--accent-h, var(--glass-fg-h)));\n /* Stained-glass — grayscale collapses the backdrop to pure LUMINANCE (akin to alpha: only lighter/\n darker survives), and the glass's OWN tint stack supplies the color — OKLCH-true to the theme, no\n hue-rotate approximation. --glass-stain-amount (0..1) sets how completely the backdrop's color is\n stripped; --glass-stain-invert (0..1, default 1) flips the surviving luminance so bright backdrop\n patches stop fighting dark tinted glass (set 0 to disable). The glass-stained utility and the switcher's Stained toggle both reference THIS token. */\n --glass-stain: grayscale(var(--glass-stain-amount, 1)) invert(var(--glass-stain-invert, 0));\n /* Colored glow for the `glow` prop — follows the foreground hue; override to recolor the halo. */\n --glass-glow: oklch(var(--glass-glow-lc) var(--glass-fg-h) / var(--glass-glow-a));\n /* Selected-control FILL (active tab, pressed toggle) — the surface twin of --active-shadow. Composed\n here, not on :root, because it reads the tint: a scoped data-glass-tint must re-resolve it, and it\n is why the chip tracks whichever theme the subtree carries. Mode knobs in tokens.css carry the two\n routes to the same look (light tints the fill, dark lets the surface show through a white lift). */\n --active-bg: oklch(var(--active-l) min(var(--glass-tint-c), var(--active-c-max)) var(--glass-tint-h) / var(--active-a));\n /* Solid PRIMARY ink (default Button, link text) — themed at the tint's OWN chroma (×1.2): selenite\n stays grey, moonstone whispers warm, jewels read clearly tinted. Lightness is the --primary-l mode knob\n (0.42 light / 0.78 dark). Chroma is min(tint × 1.2, --primary-c-max): the ×1.2 is intent — primary\n should read more saturated than the base tint — but uncapped it asked past the sRGB edge at five\n presets in light and two in dark, where the browser, not the theme, picked the delivered colour.\n Measured after capping: --primary-foreground holds Lc 88.1 light / 62.9 dark at the worst hue. */\n --primary: oklch(var(--primary-l) min(calc(var(--glass-tint-c) * 1.2), var(--primary-c-max)) var(--glass-fg-h));\n /* Ready accent-tier colors (hue angles from the anchors block; L is the mode envelope --harmonic-l). */\n --color-complement: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-complement));\n --color-analogous-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-1));\n --color-analogous-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-analogous-2));\n --color-split-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-1));\n --color-split-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-split-2));\n --color-triad-1: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-1));\n --color-triad-2: oklch(var(--harmonic-l) var(--color-chroma) var(--hue-triad-2));\n /* Monochromatic — the harmony anchor hue at stepped lightness (a subtle, low-chroma ramp). */\n --mono-1: oklch(var(--mono-l-1) var(--mono-chroma) var(--harmony-h));\n --mono-2: oklch(var(--mono-l-2) var(--mono-chroma) var(--harmony-h));\n --mono-3: oklch(var(--mono-l-3) var(--mono-chroma) var(--harmony-h));\n /* Opaque glass — a fully solid, faintly tinted floor (no see-through) for the \"opaque\" style.\n --glass-opaque-l is the body-lightness knob (:root/.dark; the header L slider drives it; the floor\n AND AutoForeground's --foreground-opaque banding both read it, so they can't drift). */\n /* Chroma is CAPPED now (--glass-opaque-c-max, tokens.css). This floor sat at L 90.9 in light (it is\n L88 now, for the same reason) — near-white territory where the sRGB ceiling collapses — yet it was\n the one near-white surface\n multiplying RAW --glass-tint-c (×1.26) with no cap, while the sheet had --glass-tint-c-hi, chakra\n had --glass-chakra-c-max and the gloss had --glass-gloss-c-max. That made it the binding surface\n for 7 of the 12 jewels: it clipped first, and every preset's chroma budget was set by it. Capping\n it is what let the tints below be normalised to their hue's real capacity rather than to this\n floor's. Multiply-THEN-cap, matching --glass-gloss-ink. */\n --glass-opaque-bg: oklch(\n calc(var(--glass-opaque-l) * 1%) min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-opaque-c-max, 0.3)) var(--glass-tint-h)\n );\n /* The same floor at the SOLIDIFY lightness/cap — the backing `glass` paints under every sheer\n material (see --glass-solidify in utilities.css). Split from --glass-opaque-bg because an opaque\n CARD and the backing under sheer glass are different surfaces with different jobs; tokens.css has\n the reasoning. Same scale, so a preset's tint still drives it; only the lightness and cap differ. */\n --glass-solidify-bg: oklch(\n calc(var(--glass-solidify-l, var(--glass-opaque-l)) * 1%)\n min(calc(var(--glass-tint-c) * var(--glass-opaque-c-scale)), var(--glass-solidify-c-max, var(--glass-opaque-c-max, 0.3))) var(--glass-tint-h)\n );\n /* Sheer glass sheet — hue/chroma from the tint vars, chroma scaled per stop so the highlights stay\n glassy and the body carries the color. Stop alphas ride the mode base --glass-sheet-a (+ fixed\n offsets; the top stop's offset is its own knob --glass-sheet-a1 — dark tapers it to 0.03);\n frosted = the same sheet lifted by --glass-frost-boost. */\n --glass-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-sheet-a1))) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-sheet-a)) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + 0.01)) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg,\n oklch(100% calc(var(--glass-tint-c-hi) * 0.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.04)) 0%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 25%,\n oklch(97% calc(var(--glass-tint-c-hi) * 1) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.02)) 50%,\n oklch(99% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost))) 75%,\n oklch(95% calc(var(--glass-tint-c-hi) * 1.3) var(--glass-tint-h) / calc(var(--glass-sheet-a) + var(--glass-frost-boost) + 0.01)) 100%\n );\n /* Very subtle white border — the lit edge of a cut sheet (alpha is the mode dial) */\n --glass-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n /* Opaque CHROME edge — the shadcn --border/--input role tokens (dividers, ghost/input outlines, any\n bare `border-border`). Previously flat neutral (oklch(0.88 0 0) / oklch(0.3 0 0)), which made them\n the ONLY chrome in the theme that did not move with the tint: a ghost Button kept a grey edge under\n lapis while its `outline` sibling (border-foreground/20) tracked the theme. Lightness moves only in\n LIGHT mode (0.88 → --border-l 0.85) to buy chroma headroom; see the knob comment in tokens.css.\n Chroma is min(tint chroma, --border-c-max) — a CAP, deliberately not --glass-tint-c-hi and not a\n multiplier. --glass-tint-c-hi (0.025) is the NEAR-WHITE cap, sized for the L 95–100% sheet stops\n and crystal floor; borrowing it here spent only 66% of the ceiling on amethyst and 17% on peridot,\n which is why the edge still read grey. A flat multiplier fails the other way: lapis declares\n c 0.15, so any factor small enough to keep it in gamut pushes the 0.07 jewels below where they\n started. min() gives every preset its full declared chroma up to the ceiling floor and clamps only\n the ones that overreach — and leaves c 0 at exactly 0, so selenite stays the neutral theme it is.\n Declaring them HERE (rather than on :root) also puts them under scripts/check-gamut.mjs, which\n scrapes engine.css for both the calc() and min() chroma shapes — the suite went 20 → 24 surface/mode\n pairs when these landed, so a later retune of the caps is measured, not asserted.\n SURFACE hue (--glass-tint-h), not --glass-fg-h: an edge belongs to the surface it bounds, matching\n --glass-border / --glass-crystal-border. In a fresco that splits the two (Muse night: surface 222,\n text 310) the border follows the panel, not the type. */\n --border: oklch(var(--border-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n --input: oklch(var(--input-l) min(var(--glass-tint-c), var(--border-c-max)) var(--glass-tint-h));\n /* The remaining shadcn SURFACE tokens — the last chroma-0 values in the theme. Same shape as --border\n for the same reason: a toggle's on-state, a hover wash and a sidebar panel all take their fill from\n these, so while they were flat neutrals those surfaces read near-white in light and near-black in\n dark in every preset. Each carries its OWN cap because the sRGB ceiling is a function of lightness\n and these sit from L 18 to L 98 — a shared cap would have to be the smallest of them (0.009, at the\n near-white sidebar) and would tint nothing. See the knobs in tokens.css. */\n --accent: oklch(var(--accent-bg-l) min(var(--glass-tint-c), var(--accent-bg-c-max)) var(--glass-tint-h));\n /* The hover wash as an IMAGE layer (glass-hover-accent). Translucent so the material still reads\n through it — a hover that paints a flat fill stops looking like glass. */\n --glass-hover-wash: color-mix(in oklch, var(--accent) 70%, transparent);\n --secondary: oklch(var(--secondary-l) min(var(--glass-tint-c), var(--secondary-c-max)) var(--glass-tint-h));\n --muted: oklch(var(--muted-l) min(var(--glass-tint-c), var(--muted-c-max)) var(--glass-tint-h));\n --sidebar: oklch(var(--sidebar-l) min(var(--glass-tint-c), var(--sidebar-c-max)) var(--glass-tint-h));\n /* Theme-aware brand gradient — follows --glass-fg-h, from the hue 3 ramp-steps out\n (8/side → step 360/17 ≈ 63.53°) to the base hue, at the mode envelope --gradient-l/-c + 0.5 alpha.\n (Axis/direction variants are computed in JS from lib/oklch-utils.ts — see the /components demo.) */\n --gradient: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53) / 0.5) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h) / 0.5) 100%\n );\n /* Full-opacity twin of --gradient for gradient TEXT (background-clip: text) — no wash alpha so glyphs stay crisp. */\n --gradient-text: linear-gradient(\n 135deg,\n oklch(var(--gradient-l) var(--gradient-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-l) var(--gradient-c) var(--glass-fg-h)) 100%\n );\n /* High-contrast gradient text for printing ON a tinted theme surface: DARK tint-hue gradient in light\n mode, LIGHT one in .dark (--gradient-contrast-l/-c), so legibility comes from lightness. */\n --gradient-text-contrast: linear-gradient(\n 135deg,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) calc(var(--glass-fg-h) + 63.53)) 0%,\n oklch(var(--gradient-contrast-l) var(--gradient-contrast-c) var(--glass-fg-h)) 100%\n );\n /* Crystal floor — clear glass; the alpha is the mode dial (--glass-crystal-bg-a). */\n /* Crystal body. The lightness is a KNOB, not 100%, and that is the whole point: at L100 sRGB holds\n ZERO chroma, so this surface asked for 0.015 and delivered exactly none — crystal rendered pure\n white under every preset, in both modes (measured: #fffdff amethyst, #fffef4 moonstone,\n #fbffff lapis; RGB spread 2–11). check-gamut even reported it as \"no attainable chroma at all\"\n and skipped the pair rather than failing it.\n --glass-crystal-l is 96 because that is the lowest lightness at which the TIGHTEST of the 21 hues\n this theme uses (h268, lapis) can still carry the 0.015 this asks for: ceiling 0.0182, 1.22x\n headroom. L96.5 is the exact crossover at 1.06x, too thin given maxChromaFor already sits 3% inside\n the boundary to stop Safari clamping toward grey. The cost is small — at the 0.3 light-mode alpha\n the composite moves ~1.2 L, and 0.4 L in dark. */\n --glass-crystal-bg: oklch(calc(var(--glass-crystal-l) * 1%) calc(var(--glass-tint-c-hi) * 0.6) var(--glass-tint-h) / var(--glass-crystal-bg-a));\n /* ── Chakra — the step-cut gem surface ────────────────────────────────────────────────────────\n BODY: the stone itself. The one tint-composing token here, so it is the only one with a gamut\n budget — min() against --glass-chakra-c-max (not a multiplier) for the same reason --border uses\n one: lapis declares c 0.15 and would be gamut-mapped, while any multiplier small enough to hold it\n drags the 0.07 jewels below where they should sit. Caps are measured per mode against the tightest\n ceiling across all 17 presets, so every preset delivers 100% of its declared chroma. */\n --glass-chakra-bg: oklch(\n calc(var(--glass-chakra-l) * 1%) min(var(--glass-tint-c), var(--glass-chakra-c-max)) var(--glass-tint-h) /\n var(--glass-chakra-a)\n );\n /* SURFACE STACK: the same gloss triple crystal bakes, so `data-gloss` white / tonal / hue reskin\n chakra exactly as they reskin crystal (those blocks remap --glass-gloss-*, which these read), over\n a fresco slot and the tint wash. Nothing here is inset, so nothing here has a corner of its own —\n the element's border-radius clips the whole stack, which is the point (see the facet bands below). */\n --glass-chakra-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n /* THE CUT — four sides, two steps, as INSET SHADOWS rather than inset background layers.\n A background layer is always a RECTANGLE: background-size shrinks it but gives it no radius of its\n own, so an inset gradient paints square corners over a rounded surface — a card with hard-cornered\n rectangles stacked on it. box-shadow has no such problem; an inset shadow is the border box's own\n rounded rect, offset, so every band follows --radius at whatever value the element carries, for\n free and at any size.\n Each side is a pair. The step-1 band (offset --glass-chakra-step) paints OVER the step-2 band\n (offset twice that), so the visible result is two concentric bands per side: 0→N shows step 1\n composited on step 2, N→2N shows step 2 alone. That is the step cut, and because step 2 is\n literally the same colour at half alpha it cannot drift out of sync with step 1.\n Order matters — box-shadow paints first-listed on top, so every step-1 band precedes its step-2\n twin. Corners: adjacent bands overlap and composite rather than mitering to a hard 45° line, which\n is the trade for following the radius. On a gem that reads as the corner facet catching both\n neighbours, and it is what the existing --glass-shadow bevel already does at top and bottom. */\n --glass-chakra-facets:\n inset 0 var(--glass-chakra-step) 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-t-a)),\n inset var(--glass-chakra-step) 0 0 oklch(var(--shadow-highlight) / var(--glass-chakra-facet-l-a)),\n inset calc(var(--glass-chakra-step) * -1) 0 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-r-a)),\n inset 0 calc(var(--glass-chakra-step) * -1) 0 oklch(var(--shadow-ink) / var(--glass-chakra-facet-b-a)),\n inset 0 calc(var(--glass-chakra-step) * 2) 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-t-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * 2) 0 0 oklch(var(--shadow-highlight) / calc(var(--glass-chakra-facet-l-a) * 0.5)),\n inset calc(var(--glass-chakra-step) * -2) 0 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-r-a) * 0.5)),\n inset 0 calc(var(--glass-chakra-step) * -2) 0 oklch(var(--shadow-ink) / calc(var(--glass-chakra-facet-b-a) * 0.5));\n /* Edge + depth: the standard drop plus the cut. --glass-shadow's own top/bottom bevel is left out —\n the facet bands ARE the bevel, and doubling them would flatten the step read. */\n --glass-chakra-border: oklch(100% calc(var(--glass-tint-c-hi) * 0.5) var(--glass-tint-h) / var(--glass-border-a));\n --glass-chakra-shadow: var(--glass-shadow), var(--glass-chakra-facets);\n --glass-chakra-shadow-hover: var(--glass-shadow-lg), var(--glass-chakra-facets);\n /* Precomposed background STACKS — the background-image compositions, single-sourced. The surface\n utilities consume them via --srf-* fallbacks (sheer utilities append the element-composed solidify\n layer underneath), and the [data-glass] style remaps below swap them wholesale. Live here so they\n re-resolve per tint scope and per mode wrapper. The crystal stack bakes the gloss into the\n background (the site-wide re-skin can't use ::before/::after — those need position:relative, which\n would break sticky/fixed surfaces). */\n --glass-wash-layer: linear-gradient(var(--glass-tint-wash), var(--glass-tint-wash));\n --glass-stack-bg: var(--glass-bg), var(--glass-wash-layer);\n /* Frosted rides the wash in the IMAGE stack (sheet + wash), leaving the background-color channel\n free for the veil floor. */\n --frosted-stack-bg: var(--glass-frosted-bg), var(--glass-wash-layer);\n --crystal-stack-bg:\n var(--glass-gloss-top), var(--glass-gloss-glow), var(--glass-gloss-streak), var(--glass-crystal-fresco, none), var(--glass-wash-layer);\n}\n/* ── Glass surface utilities (@utility — tree-shaken per consumer usage) ──────\n The material system: ONE structural `glass` utility + orthogonal axis classes (border, veil, sm/lg,\n gradient, glow, sheen). Materials live in materials.css as [data-material] token sets; page styles\n in styles.css remap the same --srf-* tokens. No recipe utilities, no !important. */\n\n/* The glow itself — a punchy two-layer colored halo, centralized so its strength is tunable in ONE\n place. `glass-glow` is the resting glow; `glass-glow-lg` (used on hover) intensifies it. Both set\n --glow-layer, which every glass box-shadow folds in, so the glow shows under EVERY glass style.\n Full mechanism + tuning + troubleshooting: docs/glow.md */\n@utility glass-glow {\n --glow-layer: 0 0 8px 1px var(--glass-glow), 0 0 18px 3px var(--glass-glow);\n}\n@utility glass-glow-lg {\n --glow-layer: 0 0 12px 2px var(--glass-glow), 0 0 28px 5px var(--glass-glow);\n}\n\n/* ── THE MATERIAL SYSTEM (Sistine 2.0) ────────────────────────────────────────\n ONE structural utility (`glass`) + orthogonal axis classes. The material (a token set) comes from\n [data-material] (materials.css) or the inherited [data-glass] page remap (styles.css); with\n neither, the --srf-* fallbacks render the default glass material. Axis utilities only SET custom\n props (or properties `glass` never touches), so Tailwind's emission order can never flip a result.\n (The legacy recipe utilities below coexist during the migration and die with it.) */\n\n/* Structural glass — background/filter/shadow plumbing only; borderless (add glass-border).\n Channels, outermost first:\n background-image = [--srf-hover state slot] + [--acc-gradient accent slot] + [--srf-bg-image material stack] + [solidify]\n background-color = --veil-floor (element veil) > --srf-bg-color (material floor) > transparent\n backdrop-filter = --srf-filter (material) > blur(--srf-blur > --blur) saturate(180%)\n box-shadow = --srf-shadow (material) > --srf-elev (size axis) > --glass-shadow, + glow\n The leading gradient slot is a no-op layer so `glass-gradient` composes over ANY material (same\n pattern as the fresco slot inside --crystal-stack-bg). Both no-op slots resolve to `none` rather\n than linear-gradient(transparent, transparent): `none` is a valid , holds the layer\n position identically, and paints nothing — where a transparent GRADIENT still goes through the\n gradient rasteriser (and its dither) to deliver zero pixels.\n The solidify floor (--glass-opacity dial) is composed HERE, at the element — never in a token\n context (the var-composition-resolves-at-declaration gotcha; see scripts/check-theme.mjs). The\n --srf-solidify slot lets a material DROP that layer when it is provably a no-op; materials that\n want the normal floor pin the slot back to `initial` (guaranteed-invalid → the var() fallback\n below composes --glass-solidify at the element, exactly as before). */\n@utility glass {\n /* Fallback 0.7, not 0: every glass surface now sits on a 70% solidify floor unless a consumer dials\n it back. Measured before changing it, because AutoForeground does NOT read --glass-opacity — it\n bands text against the sheer floor, so a more solid surface than it modelled could have cost\n contrast. Body text goes 91.5 -> 86.5 Lc in light and 88.0 -> 85.8 in dark, both far above the\n 75 body floor (it still clears at a full 1.0). Set --glass-opacity: 0 for the old sheer default. */\n /* --glass-solidify-BG, not --glass-opaque-bg: the backing under sheer glass is its own surface now\n (engine.css / tokens.css). In dark the two resolve identically, so this is a light-mode change. */\n --glass-solidify-fill: color-mix(in oklch, var(--glass-solidify-bg, var(--glass-opaque-bg)) calc(var(--glass-opacity, 0.7) * 100%), transparent);\n --glass-solidify: linear-gradient(var(--glass-solidify-fill), var(--glass-solidify-fill));\n /* --srf-hover leads the stack because a hover/selection wash has to sit ON the material, not under it.\n `hover:bg-accent` sets background-COLOR, which is the bottom of the box — beneath all 14 image layers\n the glass stack paints, of which the solidify floor alone is 70% opaque. At most ~30% of the accent\n survived to the eye, so the hover read as the material's own colour (white in light mode) no matter\n what --accent was set to. No amount of tuning the token could fix that; it is a layer-order problem.\n Same no-op `none` slot pattern as --acc-gradient below it. */\n background-image:\n var(--srf-hover, none), var(--acc-gradient, none), var(--srf-bg-image, var(--glass-stack-bg)), var(--srf-solidify, var(--glass-solidify));\n background-color: var(--veil-floor, var(--srf-bg-color, transparent));\n /* Author ONLY the standard backdrop-filter (no -webkit- twin: Lightning dedupes the pair keeping\n the PREFIXED one, which Chromium never aliased — the [no-webkit-twin] invariant guards this).\n The max() composes the diffuse FLOOR for adaptive surfaces. */\n backdrop-filter: var(--srf-filter, blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%)) var(--srf-stain,);\n box-shadow: var(--srf-shadow, var(--srf-elev, var(--glass-shadow))), var(--glow-layer, 0 0 0 0 transparent);\n}\n\n/* Border axis — the material picks the color; width resolves material pin (--srf-border-w, which\n itself reads the --brd-w weight slot) → weight slot directly (adaptive surfaces) → 1px hairline. */\n@utility glass-border {\n border: var(--srf-border-w, var(--brd-w, 1px)) solid var(--srf-border-color, var(--glass-border));\n}\n/* Border WEIGHTS — token setters for the --brd-w slot (order-safe; same-element composition, like\n glass-sm's --srf-blur). Hairline is the unmarked default (1px; 0.5px under frosted); rim = 2px, frame = 4px. */\n@utility glass-border-rim {\n --brd-w: 2px;\n}\n@utility glass-border-frame {\n --brd-w: 4px;\n}\n\n/* Diffuse axis — a READABILITY BLUR FLOOR (not a blur value): every translucent material computes\n blur(max(, --dif)), so crystal stays crystal until a text-dense surface opts in.\n Token-setter (order-safe); OPAQUE never reads the slot (--srf-filter: none), so the short-circuit\n is inherited — no GPU spend on solid floors. Tune the floor via --glass-diffuse. */\n@utility glass-diffuse {\n --dif: var(--glass-diffuse, 12px);\n}\n\n/* Veil axis — the legibility floor for read-through overlays (menus, tooltips, toasts).\n Composed AT THE ELEMENT so a scoped --glass-solid-a resolves here (check-theme invariant 2b).\n The veil rides the background-COLOR channel, under the material's image stack, and — being\n element-declared — beats any inherited --srf-bg-color remap. */\n/* Stained axis — REAL stained-glass optics: grayscale collapses the backdrop to pure luminance\n (lighter/darker only, akin to alpha), so the glass's OWN tint supplies every drop of color and\n whatever sits behind renders as tonal shades of the theme. One color-matrix op — free next to the\n blur. --glass-stain-amount (0..1) dials how completely the backdrop's color is stripped. */\n@utility glass-stained {\n --srf-stain: var(--glass-stain);\n}\n\n@utility glass-veil {\n --veil-floor: oklch(var(--glass-solid-l) 0 0 / var(--glass-solid-a));\n}\n\n/* THE PAGE BACKDROP LAYER — the fixed, full-viewport ground every sheer material blurs. Put this on the\n `fixed inset-0 -z-10` container each background component renders.\n It exists to force a compositing layer. `backdrop-filter` re-samples everything painted beneath it,\n and this layer sits at z-index -10 under the entire document; unpromoted, the compositor has to\n re-rasterise it against the scroll position every frame, which is where the scroll artifact on the\n sheer materials comes from. `opaque` is the ONE material that sets --srf-filter: none — and the one\n material that never showed the artifact, which is the correlation that points here.\n Measured on the deployed page, this element resolved will-change:auto / transform:none /\n contain:none — nothing was promoting it. Promoting is safe specifically because it has ZERO\n position:fixed descendants and no background-attachment:fixed; a transform/will-change would\n otherwise become their containing block and reparent them. Re-check that before adding children.\n will-change (rather than a translateZ hack) because the hint is honest here: this layer is on screen\n and being sampled for the whole session, so there is nothing to hint OFF later. */\n@utility glass-backdrop-layer {\n will-change: transform;\n}\n\n/* Size axes — blur + elevation tiers. These feed the GLASS material only: frosted/crystal/opaque\n (and page remaps) set --srf-filter/--srf-shadow wholesale, which flattens the tiers — matching\n the old system, where the [data-glass] styles flattened glass-surface-sm/-lg. */\n@utility glass-sm {\n --srf-blur: var(--blur-sm);\n --srf-elev: var(--glass-shadow-sm);\n}\n@utility glass-lg {\n --srf-blur: var(--blur-lg);\n --srf-elev: var(--glass-shadow-lg);\n}\n@utility glass-xl {\n --srf-blur: var(--blur-xl);\n --srf-elev: var(--glass-shadow-lg);\n}\n\n/* ── Gradient ACCENT — a brand wash ON a material, not a material. Fills the no-op --acc-gradient\n slot at the top of every `glass` background, so it composes over glass, frosted, crystal, and\n opaque alike. A token-setter (no property `glass` sets), so it's immune to utility emission order. */\n@utility glass-gradient {\n --acc-gradient: var(--gradient);\n}\n\n/* ── Hover/selection WASH — the interactive twin of glass-gradient. Fills the --srf-hover slot at the\n very top of the background stack, which is the only place a state colour is actually visible on a\n glass surface: `hover:bg-accent` writes background-COLOR, and every one of the material's image\n layers paints over it (solidify alone is 70% opaque), so the accent arrived at ~30% strength blended\n into the material and read as plain white on a light theme.\n The wash is deliberately translucent rather than a flat fill — glass that turns solid on hover stops\n looking like glass. 70% of --accent over the material keeps the wash, gloss and blur reading through\n it while still shifting the surface unmistakably toward the theme colour.\n Pair with `hover:bg-accent` rather than replacing it: the background-color still does the work on the\n NON-glass variants (outline/default paint no material, so nothing covers it there). */\n@utility glass-hover-accent {\n &:hover {\n --srf-hover: linear-gradient(var(--glass-hover-wash), var(--glass-hover-wash));\n }\n}\n\n/* Sheen — the OPT-IN hover shimmer (the gloss slides across on hover). Self-contained (highlight + diagonal\n + glow + the 200%/hover shift), so it works on any glass surface AND, layered on glass-crystal (which\n already has the static gloss), its ::before/::after override crystal's to add the motion. Pseudo-element\n based → position:relative; avoid on sticky/fixed surfaces. */\n@utility glass-sheen {\n position: relative;\n\n &::before,\n &::after {\n content: \"\";\n position: absolute;\n inset: 0;\n border-radius: inherit;\n pointer-events: none;\n }\n\n &::before {\n background: var(--glass-gloss-top), var(--glass-gloss-streak);\n background-size: 200% 200%;\n background-position: 0% 0%;\n opacity: 0.7;\n transition: background-position 0.3s ease;\n }\n\n &::after {\n background: var(--glass-gloss-glow);\n opacity: 0.8;\n }\n\n &:hover::before {\n background-position: 100% 100%;\n }\n}\n\n@keyframes shimmer {\n 0% {\n transform: translateX(-100%);\n }\n 100% {\n transform: translateX(100%);\n }\n}\n\n/* ── Reduced motion ───────────────────────────────────────────────────────────\n CONTRIBUTING says \"Respect prefers-reduced-motion\"; the pattern scenes did and nothing else did.\n This closes that, but NOT with the blunt global `*, ::before, ::after { animation: none !important }`\n reset — that would also flatten the pattern scenes' own carefully scoped rules and every enter/exit\n transition in the library.\n\n The treatment differs by what the animation actually IS, because the preference is about MOTION, not\n about animation in general:\n - shimmer → translation across a surface. Real motion, purely decorative: stopped. The skeleton\n still reads as a skeleton from its base fill.\n - spin → rotation. Real motion, but it is the ONLY signal that a loader is alive, so freezing\n it reads as \"hung\" rather than \"calm\". Slowed hard instead of stopped.\n - pulse → opacity only. Nothing moves, so there is nothing to trigger a vestibular response.\n Left alone deliberately; it also carries meaning (the input-otp caret is a blink).\n Unlayered on purpose: Tailwind's generated utilities sit inside @layer utilities, and an unlayered\n rule outranks any layered one regardless of order, so this needs neither !important nor a specificity\n hack. */\n@media (prefers-reduced-motion: reduce) {\n .animate-\\[shimmer_2s_infinite\\],\n .before\\:animate-\\[shimmer_2s_infinite\\]::before {\n animation: none;\n }\n\n .animate-spin {\n animation-duration: 3s;\n }\n}\n/* ── Materials — [data-material] token sets + gloss flavors ───────────────────\n Four materials, one attribute. Each block declares the FULL --srf-* pin set ON the element, so an\n explicit material always beats an inherited [data-glass] page remap (element-declared custom props\n beat inherited ones). One material per element is structural: attributes can't stack, so there is\n no !important anywhere in the system.\n NOTE these are inherited custom properties — a data-material element also acts as a SCOPED default\n for adaptive `glass` descendants (the mechanism DialogContent's old data-glass hack hand-rolled).\n Pin a child back with its own data-material.\n Import order: after engine.css (the [data-gloss] blocks must beat the engine's gloss defaults by\n source order at equal specificity on ). */\n\n[data-material=\"glass\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-stack-bg);\n --srf-bg-color: transparent;\n /* Composed on the element, so the glass-sm/glass-lg axes still resolve here (same-element\n setter/reader is safe — the composition gotcha only bites pre-composed inherited values). */\n --srf-filter: blur(max(var(--srf-blur, var(--blur)), var(--dif, 0px))) saturate(180%);\n --srf-border-color: var(--glass-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--srf-elev, var(--glass-shadow));\n}\n\n/* Frosted — the tint wash rides the IMAGE stack (--frosted-stack-bg = sheet + wash), leaving the\n background-color channel free for the veil floor. */\n[data-material=\"frosted\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--frosted-stack-bg);\n --srf-bg-color: transparent;\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n\n/* Crystal — gloss BAKED into the background stack (sticky/fixed-safe; no pseudo-elements, no\n position:relative). --crystal-stack-bg carries the --glass-crystal-fresco pass-through. The hover\n sparkle is a plain token swap; box-shadow transitions on the real property. No saturate(),\n matching the old variant. */\n[data-material=\"crystal\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-crystal-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"crystal\"]:hover {\n --srf-shadow: var(--glass-crystal-shadow-hover);\n}\n/* Crystal text tiers — the baked gloss locally LIGHTENS the title zone (worst in dark mode), so text\n inside crystal remaps to the -crystal set AutoForeground bands against the glossed crystal surface.\n VEILED crystal is excluded: the veil floor is exactly what the normal tiers are banded for. */\n[data-material=\"crystal\"]:not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n\n/* Chakra — the step-cut gem: a translucent tinted body with a two-step facet frame on all four edges,\n sitting between crystal (near-clear) and opaque (solid). Like crystal it bakes everything into the\n background stack and the shadow — no pseudo-elements, so it is safe on sticky/fixed surfaces, and it\n carries the same gloss triple, so `data-gloss` white / tonal / hue reskin it too.\n The cut lives entirely in --srf-shadow (see --glass-chakra-facets in engine.css). Nothing here is an\n inset BACKGROUND layer, deliberately: background layers are rectangles with no radius of their own,\n so an inset gradient stacks square corners on a rounded surface. Inset shadows follow --radius.\n Because the stack is plain full-bleed layers, the sheer-glass axes work untouched — glass-diffuse\n raises --dif into the blur below, and the --glass-opacity solidify floor composes under the stack\n exactly as it does for crystal. */\n[data-material=\"chakra\"] {\n /* Solidify slot: `initial` = guaranteed-invalid, so `glass` falls back to composing --glass-solidify\n AT THE ELEMENT (never inherited pre-composed — the var-composition gotcha). Pinned so an opaque\n PAGE remap can never leak its dropped layer into an explicit material. */\n --srf-solidify: initial;\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-border-w: var(--brd-w, 1px);\n --srf-shadow: var(--glass-chakra-shadow);\n transition: box-shadow 0.3s ease;\n}\n[data-material=\"chakra\"]:hover {\n --srf-shadow: var(--glass-chakra-shadow-hover);\n}\n/* Chakra text tiers — content sits on the body, whose lightness (--glass-chakra-l) is its own dial and\n can sit well away from the page's. Same reasoning as opaque/crystal; veiled chakra is excluded\n because the veil floor is exactly what the NORMAL tiers are banded for. */\n[data-material=\"chakra\"]:not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n\n/* Opaque — solid tinted floor, nothing to blur through. --glass-opaque-outline stays settable on any\n ancestor: it's composed HERE, on the element, so it resolves where it's read. The floor can be\n LIGHT while the page is dark, so the foreground tiers remap to the -opaque set AutoForeground\n bands against that floor (inherits into the card's content). */\n[data-material=\"opaque\"] {\n /* DROP the solidify layer: it is `--glass-opaque-bg` at --glass-opacity alpha painted directly over a\n background-COLOR of the same --glass-opaque-bg at alpha 1 — i.e. C over C, which composites to C for\n ANY alpha. Measured across tints and both modes it moves every channel by exactly 0. So the layer was\n one full gradient rasterisation (and one dither pass) for provably zero pixels, on the one material\n with no backdrop-filter behind it to mask that noise. Restored below when the surface is VEILED —\n there the background-color is the neutral veil floor, not --glass-opaque-bg, so the layer is real\n (dropping it there would shift up to 31/255 in light and 42/255 in dark). */\n --srf-solidify: none;\n /* NO sheet either — opaque now paints a FLAT background-color and nothing else. --glass-bg is a 5-stop\n gradient whose total range, composited over this floor, is 2–4 sRGB codes across an entire card in\n light and 5–7 in dark: one code per ~100–200px, far too shallow to draw cleanly in 8-bit, so the\n rasteriser dithers rather than bands. On every other material a real backdrop-filter supplies blurred\n page content that masks that noise; opaque sets --srf-filter: none, so there is nothing behind it and\n the dither is the only variation on the card — read as grain, worst over text.\n The sheet's GRADIENT was therefore invisible, but its uniform LIFT was not (+1.7 L light, +4.8 L dark),\n so it is folded into --glass-opaque-l rather than dropped: see tokens.css. AutoForeground reads that\n same token, so the -opaque text tiers re-band against the new floor automatically. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n /* Outline WEIGHT: an element rim/frame class (--brd-w) beats the page-level outline weight (the\n switcher's --glass-opaque-outline-w on ), which beats the 1px hairline. */\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n --srf-shadow: var(--glass-shadow);\n /* Veiled overlays INSIDE an opaque element compute a fully solid floor — same guarantee the opaque\n PAGE style makes (menus/toasts must stay legible on a solid ground). */\n --glass-solid-a: 1;\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n\n/* Veiled opaque restores the solidify floor: glass-veil moves the background-COLOR channel to the\n neutral --veil-floor, so the layer is no longer painting --glass-opaque-bg over itself and carries\n the surface's entire tint. Mirrors crystal's `:not(.glass-veil)` carve-out. */\n[data-material=\"opaque\"].glass-veil {\n --srf-solidify: initial;\n}\n\n/* ── Gloss flavors ────────────────────────────────────────────────────────────\n data-gloss on (or any ancestor) swaps the crystal gloss. Default (unset) = tonal. Token\n remaps only — they compose with [data-material=\"crystal\"] AND the [data-glass=\"crystal\"] page\n style, in both modes. */\n[data-gloss=\"white\"] {\n --glass-gloss-ink: var(--shadow-highlight); /* 100% 0 0 — the classic flat white specular */\n}\n[data-gloss=\"tonal\"] {\n /* Explicit tonal (also the default) — re-asserted so it can override a white/hue set on an ancestor. */\n --glass-gloss-ink: calc(var(--glass-gloss-l, 94) * 1%) min(calc(var(--glass-tint-c) * var(--glass-gloss-tint, 2)), var(--glass-gloss-c-max, 0.3))\n var(--glass-tint-h);\n}\n[data-gloss=\"hue\"] {\n /* Hue OWNS its lightness rather than sharing the tonal twin. The two flavors want opposite things\n from the same number: tonal wants the brightest value the surface allows (97 in light) so it reads\n as a specular, while hue needs chroma headroom — its stops ask --glass-gloss-c 0.16, and the sRGB\n ceiling at L 97 is 0.0136, so the iridescent sweep would deliver 8% of its colour and wash out to\n near-white exactly where it is supposed to be most vivid.\n 74 is mode-invariant on purpose: it holds 81% of that chroma, and it lands on the right side of\n both surfaces anyway (+18.4 L over the dark floor, −9.0 over the light one — for an iridescent\n sweep, being SEEN matters more than which direction it moves). The demo's flavor switcher already\n seeded 74 at runtime for this reason; this makes a no-JS consumer get it too.\n \"Holds 81% of that chroma\" assumed the browser gamut-MAPS an over-ceiling request down to the\n ceiling. Measured in WebKit it CLIPS instead (see --glass-gloss-c-max in tokens.css), so 0.16 did\n not deliver 81% of itself — it drove the tightest hues to a pinned channel. 0.12 sits just under\n 0.1240, the L-74 ceiling for turquoise (h190) and the tightest of the presets, so the sweep now\n renders every hue it asks for. */\n --glass-gloss-l: 74;\n /* Iridescent — each highlight sweeps (tint hue − span) → tint hue → (+ span), interpolated IN OKLCH so the\n arc passes through the base hue. Top leans to the low end, glow to the high end, streak spans both. */\n --glass-gloss-top: linear-gradient(\n to bottom in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.4\n ),\n transparent 35%\n );\n --glass-gloss-streak: linear-gradient(\n 115deg in oklch,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) - var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 0%,\n oklch(calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12) var(--glass-tint-h) / 0.12) 45%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.32\n )\n 85%,\n transparent\n );\n --glass-gloss-glow: radial-gradient(\n circle at 50% 120%,\n oklch(\n calc(var(--glass-gloss-l, 94) * 1%) var(--glass-gloss-c, 0.12)\n calc(var(--glass-tint-h) + var(--glass-gloss-hue-span, 40) * var(--glass-gloss-hue-dir, 1)) /\n 0.25\n ),\n transparent 70%\n );\n /* Kill the white: the default crystal floor is oklch(100% …) = PURE white (chroma collapses at L100), and\n the crystal shadow carries a white inset bevel. In the iridescent flavor, replace the floor with a\n hue-carrying one at the gloss lightness (so Light deepens it) and drop the bevel — leaving only swept hue. */\n --glass-crystal-bg: oklch(calc(var(--glass-gloss-l, 94) * 1%) calc(var(--glass-tint-c) * 1.2) var(--glass-tint-h) / 0.4);\n --glass-crystal-shadow: var(--glass-shadow);\n --glass-crystal-shadow-hover: var(--glass-shadow-lg);\n}\n/* ── Site-wide style remaps + vendor cascade fixes ──────────────────────────── */\n\n/* Keep react-day-picker from painting over the glass background */\n@layer utilities {\n .glass[class*=\"rdp\"],\n .glass .rdp,\n .glass .rdp-month {\n background-color: transparent !important;\n }\n}\n\n/* ── Site-wide glass style: token remaps ─────────────────────────────────────\n data-glass on (frosted | crystal | opaque) re-skins every ADAPTIVE surface (`glass` with no\n data-material) by remapping the --srf-* tokens it reads. An element-level [data-material] pins its\n own tokens and beats these by cascade physics — reinforced by the :where() wrapper, which drops\n every remap (incl. its cross-product legs) to specificity (0,0,0), so a co-located data-material on\n a toast or tint-scope element always wins. The cross-product legs keep the swapped stacks\n re-resolving inside scoped tints and status toasts (a bare [data-glass] rule would bake 's\n tint into descendants' surfaces). Veiled surfaces (glass-veil) keep their element-composed floor. */\n:where(\n [data-glass=\"frosted\"],\n [data-glass=\"frosted\"] [data-glass-tint],\n [data-glass=\"frosted\"][data-glass-tint],\n [data-glass=\"frosted\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--frosted-stack-bg);\n /* NO --srf-bg-color wash here: the wash already rides the IMAGE stack (--frosted-stack-bg), same\n as the frosted material — a color-channel copy double-washed tinted presets and stole the veil\n floor's channel. */\n --srf-filter: blur(max(var(--blur-frosted), var(--dif, 0px))) saturate(200%);\n --srf-border-color: var(--glass-frosted-border);\n --srf-border-w: var(--brd-w, 0.5px);\n --srf-shadow: var(--glass-frosted-shadow);\n}\n:where(\n [data-glass=\"crystal\"],\n [data-glass=\"crystal\"] [data-glass-tint],\n [data-glass=\"crystal\"][data-glass-tint],\n [data-glass=\"crystal\"] [data-sonner-toast][data-type]\n) {\n /* Gloss rides inside --crystal-stack-bg (baked — sticky/fixed-safe). Shimmer stays the opt-in\n glass-sheen. No saturate(), matching the material. */\n --srf-bg-image: var(--crystal-stack-bg);\n --srf-bg-color: var(--glass-crystal-bg);\n --srf-filter: blur(max(var(--blur-crystal), var(--dif, 0px)));\n --srf-border-color: var(--glass-crystal-border);\n --srf-shadow: var(--glass-crystal-shadow);\n}\n/* Crystal page: ADAPTIVE glass (no explicit material, not veiled) gets the -crystal text tiers, same\n as the material remap in materials.css — the baked gloss lightens title zones there too. */\n[data-glass=\"crystal\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-crystal);\n --foreground-soft: var(--foreground-soft-crystal);\n --foreground-strong: var(--foreground-strong-crystal);\n --foreground-ui: var(--foreground-ui-crystal);\n --muted-foreground: var(--muted-foreground-crystal);\n}\n/* Chakra page — every adaptive surface becomes a step-cut gem. The cut rides --srf-shadow, so it\n follows each surface's own border-radius with nothing to keep in sync. */\n:where(\n [data-glass=\"chakra\"],\n [data-glass=\"chakra\"] [data-glass-tint],\n [data-glass=\"chakra\"][data-glass-tint],\n [data-glass=\"chakra\"] [data-sonner-toast][data-type]\n) {\n --srf-bg-image: var(--glass-chakra-stack-bg);\n --srf-bg-color: var(--glass-chakra-bg);\n --srf-filter: blur(max(var(--blur-chakra), var(--dif, 0px)));\n --srf-border-color: var(--glass-chakra-border);\n --srf-shadow: var(--glass-chakra-shadow);\n}\n/* Chakra page: adaptive glass (no explicit material, not veiled) takes the -chakra text tiers, same\n as the material remap in materials.css. */\n[data-glass=\"chakra\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-chakra);\n --foreground-soft: var(--foreground-soft-chakra);\n --foreground-strong: var(--foreground-strong-chakra);\n --foreground-ui: var(--foreground-ui-chakra);\n --muted-foreground: var(--muted-foreground-chakra);\n}\n/* Opaque — solid tinted panels for adaptive surfaces. --glass-solid-a: 1 makes any veiled overlay\n (glass-veil) compute a FULLY solid floor here too, so menus/toasts stay legible on an opaque page. */\n:where(\n [data-glass=\"opaque\"],\n [data-glass=\"opaque\"] [data-glass-tint],\n [data-glass=\"opaque\"][data-glass-tint],\n [data-glass=\"opaque\"] [data-sonner-toast][data-type]\n) {\n /* Flat floor, no sheet — same reasoning as [data-material=\"opaque\"] in materials.css: with\n --srf-filter: none there is no blurred backdrop to mask the dither of a 2–7 code gradient, and the\n sheet's uniform lift is folded into --glass-opaque-l instead. */\n --srf-bg-image: none;\n --srf-bg-color: var(--glass-opaque-bg);\n --srf-filter: none;\n --srf-shadow: var(--glass-shadow);\n}\n/* --glass-solid-a CANNOT ride in the :where() block above. :where() is specificity ZERO by design (so a\n page style never beats an explicit material), and every other declaration up there survives that\n because nothing else declares them — they are material SLOTS with no competing value. This one is\n different: tokens.css declares --glass-solid-a on :root, which is (0,1,0), so the :where() version\n lost every time and the opaque page never actually got its solid floor. Measured: --glass-solid-a\n resolved to 0.65 on under [data-glass=\"opaque\"], identical to every other page style.\n The visible symptom is any VEILED surface on an opaque page staying translucent while the solid\n panels around it go opaque — menus, toasts and popovers, which is exactly what the block above says\n this is for. A bare attribute selector is (0,1,0), which ties :root and wins on source order because\n globals.css imports styles.css after tokens.css. It is a plain literal, so it simply inherits into\n the tint / toast scopes and needs none of their re-resolution. */\n[data-glass=\"opaque\"] {\n --glass-solid-a: 1;\n}\n/* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —\n an explicit frosted/crystal card keeps its translucency AND its normal foreground). Declared ON the\n element because (a) --glass-opaque-outline is settable on any ancestor and must resolve where it's\n read, and (b) the foreground remap must not leak into explicit materials. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n /* Same provable no-op as [data-material=\"opaque\"]: this page remap puts --glass-opaque-bg in the\n background-COLOR channel, so the solidify layer paints that colour over itself. Element-level and\n :not([data-material]) so it cannot leak into an explicit material, :not(.glass-veil) because a\n veiled floor is neutral and the layer is real there. */\n --srf-solidify: none;\n}\n/* The uniform EDGE applies to every adaptive panel, veiled or not — a veiled card under the opaque page\n still reads as a solid panel and needs the same outline. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]) {\n --srf-border-color: var(--glass-opaque-outline, var(--glass-border));\n --srf-border-w: var(--brd-w, var(--glass-opaque-outline-w, 1px));\n /* Paint the border HERE, not just the tokens: borderless roles (Card default) never render\n glass-border, so without this only bordered roles showed the outline — \"some cards get it,\n some don't\". Under the opaque page every solid panel takes the uniform edge; bordered elements\n resolve the same vars, so nothing double-draws. */\n border: var(--srf-border-w) solid var(--srf-border-color);\n}\n/* The FOREGROUND remap is :not(.glass-veil) — the same carve-out crystal and chakra make, and the same\n one the --srf-solidify rule above already makes for this page style. A veiled card does not paint\n --glass-opaque-bg: glass-veil moves the background-COLOR channel to the neutral --veil-floor, so its\n text sits on the veil surface the BASE tokens are banded for, not on the opaque floor. Remapping it\n here handed those cards ink solved for a surface they do not have — visible as a hue jump, since the\n opaque floor is the one surface with nothing composited over it and so sits at the raw --glass-tint-h\n while every sheer surface lands a few degrees off it (measured on the /colors panel: 22.0 vs 14.6).\n Splitting the rule is what keeps the edge treatment while dropping the foreground one. */\n[data-glass=\"opaque\"] :where(.glass):not([data-material]):not(.glass-veil) {\n --foreground: var(--foreground-opaque);\n --foreground-soft: var(--foreground-soft-opaque);\n --foreground-strong: var(--foreground-strong-opaque);\n --foreground-ui: var(--foreground-ui-opaque);\n --muted-foreground: var(--muted-foreground-opaque);\n}\n/* ── Glass tint presets ─────────────────────────────────────────────────────\n Set data-glass-tint on any element (commonly ) to recolor the glass in that subtree.\n Most presets just move the tint hue/chroma; \"sistine\" is a bespoke multi-hue fresco (gilded\n gold → terracotta flesh tones → azure sky), echoing the Sistine Chapel ceiling.\n Wash + all tint-derived tokens re-resolve per-scope via the :root,[data-glass-tint] split above. */\n[data-glass-tint=\"sapphire\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.19;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.056;\n}\n[data-glass-tint=\"aventurine\"] {\n --glass-tint-h: 158;\n --glass-tint-c: 0.076;\n --glass-tint-a: 0.54;\n --glass-wash-l: 88%;\n --glass-opaque-c-max: 0.195;\n}\n[data-glass-tint=\"amethyst\"] {\n --glass-tint-h: 300;\n --glass-tint-c: 0.101;\n --glass-tint-a: 0.15;\n --glass-wash-l: 55%;\n --glass-opaque-c-max: 0.065;\n}\n[data-glass-tint=\"rose\"] {\n --glass-tint-h: 8;\n --glass-tint-c: 0.095;\n --glass-tint-a: 0.17;\n --glass-wash-l: 64%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"amber\"] {\n --glass-tint-h: 75;\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.38;\n --glass-wash-l: 81%;\n --glass-opaque-c-max: 0.096;\n}\n[data-glass-tint=\"carnelian\"] {\n --glass-tint-h: 38;\n --glass-tint-c: 0.081;\n --glass-tint-a: 0.19;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.062;\n}\n[data-glass-tint=\"peridot\"] {\n --glass-tint-h: 128;\n --glass-tint-c: 0.083;\n --glass-tint-a: 0.54;\n --glass-wash-l: 92%;\n --glass-opaque-c-max: 0.218;\n}\n[data-glass-tint=\"turquoise\"] {\n --glass-tint-h: 190;\n --glass-tint-c: 0.057;\n --glass-tint-a: 0.54;\n --glass-wash-l: 90%;\n --glass-opaque-c-max: 0.144;\n}\n[data-glass-tint=\"aquamarine\"] {\n --glass-tint-h: 215;\n --glass-tint-c: 0.053;\n --glass-tint-a: 0.5;\n --glass-wash-l: 82%;\n --glass-opaque-c-max: 0.091;\n}\n[data-glass-tint=\"tourmaline\"] {\n --glass-tint-h: 342;\n --glass-tint-c: 0.106;\n --glass-tint-a: 0.18;\n --glass-wash-l: 67%;\n --glass-opaque-c-max: 0.079;\n}\n[data-glass-tint=\"lapis\"] {\n /* Lapis lazuli IS the deep saturated ultramarine, and at plain jewel chroma it reads as just another\n blue next to sapphire. It used to buy that depth by declaring c 0.15 — far past the sRGB ceiling —\n and letting the browser clip. That DID deliver a deep blue, but by accident rather than by design:\n clipping chops channels, which drops LIGHTNESS, and the saturation came from landing darker.\n Measured, oklch(58% 0.375 268) renders L48.1 C0.2941 h271 — a colour nobody wrote down.\n The wash lightness below asks for that colour on purpose instead. h268's chroma ceiling PEAKS at\n L46 (0.2979) and collapses to 0.1293 by L74, so the shared 72/58 wash was sitting on the downslope;\n at L46 the same depth is available inside the gamut. oklch(45% 0.291 268) renders L44.9 C0.2909\n h267.9 — the same deep ultramarine, hue exact, nothing pinned.\n So lapis is no longer an exception to anything: it is the clearest case of the rule the whole\n palette now follows — put the wash where the hue can actually hold chroma, then ask for it. */\n --glass-tint-h: 268;\n --glass-tint-c: 0.085;\n --glass-tint-a: 0.13;\n --glass-wash-l: 46%;\n --glass-opaque-c-max: 0.055;\n}\n[data-glass-tint=\"goldstone\"] {\n --glass-tint-h: 22;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.17;\n --glass-wash-l: 63%;\n --glass-opaque-c-max: 0.06;\n}\n/* ── Per-mode wash ALPHA ──────────────────────────────────────────────────────\n Alpha slides the surface along a straight line from the material floor toward the wash colour, so\n how much a preset can take is set by how far its wash sits from THAT MODE's floor (L36.4 dark /\n L88 light). A wash near the floor barely moves the surface and tolerates a lot; a wash far from it\n crosses the mid-tone band where no text polarity reaches the body floor, and tolerates little.\n Because every wash now sits at its hue's chroma PEAK, dark-peaking hues (lapis L46, amethyst L55)\n sit near the DARK floor and take high alpha there, while light-peaking hues (peridot L92, turquoise\n L90) sit near the LIGHT floor and take high alpha there. The two columns are near mirrors.\n Each value is HALF that mode's measured headroom, so every preset keeps body text >= 75 Lc in both\n modes with room to spare. A single shared alpha cannot serve both ends: at 0.55 everywhere, five\n presets lose dark-mode text and lapis/amethyst lose light-mode text.\n The switcher no longer inlines --glass-tint-a for a named preset (see applyTint), so THESE are the\n values that render — nothing shadows them. */\n.dark[data-glass-tint=\"rose\"] {\n --glass-tint-a: 0.49;\n}\n.dark[data-glass-tint=\"goldstone\"] {\n --glass-tint-a: 0.5;\n}\n.dark[data-glass-tint=\"carnelian\"] {\n --glass-tint-a: 0.45;\n}\n.dark[data-glass-tint=\"amber\"] {\n --glass-tint-a: 0.34;\n}\n.dark[data-glass-tint=\"peridot\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aventurine\"] {\n --glass-tint-a: 0.29;\n}\n.dark[data-glass-tint=\"turquoise\"] {\n --glass-tint-a: 0.28;\n}\n.dark[data-glass-tint=\"aquamarine\"] {\n --glass-tint-a: 0.31;\n}\n.dark[data-glass-tint=\"sapphire\"] {\n --glass-tint-a: 0.48;\n}\n.dark[data-glass-tint=\"lapis\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"amethyst\"] {\n --glass-tint-a: 0.54;\n}\n.dark[data-glass-tint=\"tourmaline\"] {\n --glass-tint-a: 0.46;\n}\n[data-glass-tint=\"moonstone\"] {\n /* Moonstone — a warm, very-low-chroma off-white glass (reads as cream, not yellow). A normal translucent\n tint: texture-preserving and light-text in both modes, just a desaturated warm hue. Anchors its harmony\n wheel at 0° (like selenite) so accents come out a colorful set, not a warp of the near-neutral hue. */\n --glass-tint-h: 75;\n --glass-tint-c: 0.047;\n --glass-tint-a: 0.18;\n --harmony-h: 0;\n /* Gloss chroma, moonstone's own. The global --glass-gloss-tint is 4.25 — sized for the JEWELS, where a\n 0.07–0.1 tint wants a vivid gem highlight. Moonstone declares 0.047 precisely so it \"reads as cream,\n not yellow\" (above), and 4.25× asks for 0.1998.\n --glass-gloss-c-max (tokens.css) is what stops that becoming a clipped primary, and in LIGHT it does\n the whole job: 0.013 caps both 0.1998 and 0.047 to the same value, so this override changes nothing\n there. It earns its place in DARK, where the cap is a roomy 0.11 — enough that the multiplier would\n still put moonstone's highlight at 2.3× its declared chroma, i.e. amber on a stone that is supposed\n to be pale. 1× holds the specular at the tint's own chroma and lets its LIGHTNESS do the work. */\n --glass-gloss-tint: 1;\n /* Moonstone daytime body lightness. Fitted for the retired opaque sheet (tokens.css) on moonstone's OWN\n floor rather than the global pair: this cream is already near-white, where a near-white sheet moved it\n far less than it moved the standard L90 floor. dE 0.0002 — indistinguishable from the old surface. */\n --glass-opaque-l: 94.5;\n /* NO pinned --glass-opaque-bg: the floor must stay DERIVED from --glass-opaque-l (engine), because the\n lightness slider / stored per-mode L land as an INLINE --glass-opaque-l that shadows this block — a\n literal pin would freeze the paint while AutoForeground keeps banding text against the token (light\n text on cream). Was c-scale 1 — the value that made the derived color exactly the original cream when\n the sheet still painted over it; 0.92 is that same cream now that the sheet is gone (it desaturated as\n well as lightened, so both knobs carry part of it). */\n --glass-opaque-c-scale: 0.92;\n}\n.dark[data-glass-tint=\"moonstone\"] {\n /* Moonstone night. The wash sits a few points above the standard 58% (warmth without glare; h75\n carries more real luminance per OKLCH point than the cool tints, so pale-warm washes read brighter\n than their numbers). AutoForeground reads BOTH wash knobs live (+ the [moonstone-sync] mirrored\n fallback), so page text and per-card dark-cream text re-band automatically.\n\n The FLOOR is what you actually see: at the default opacity every surface — cards, inputs, the nav\n pill — paints --glass-opaque-bg, and the wash only tints what shows through. It used to derive\n oklch(0.8 0.047 75), which is a sand/tobacco tan, so moonstone night read as brown rather than as\n the pale silvery stone it is named for. Lightness alone does not fix that: h75 is warm, so every\n lightness along it lands somewhere in the tan family. Cutting the CHROMA is what moves it off that\n family. Light mode is untouched (it keeps c-scale 1 at L94, where the same hue reads as cream).\n\n L52, down from a cream L84.9 — moonstone night is a DARK stone now, and that is forced rather than\n preferred. This floor feeds two surfaces at once: opaque cards paint it directly, and it composites\n at --glass-opacity over the dark veil to become the floor sheer glass sits on. Both have to clear\n the body floor, and for h75 nothing in L55–L84 reaches it in EITHER text polarity — that band is the\n mid-tone dead zone. A cream L84.9 put the opaque card just above it (Lc 77.1) but dragged the sheer\n surface to L65, dead centre, where body text topped out at 65.4 Lc and the preset had to be pinned\n as a known exception. Going lighter cannot fix it: the veil is 30% of the composite, which caps the\n solidify floor at L75.6 no matter how light this value gets, so the sheer surface can never climb\n out the top. Only the bottom exit is reachable. L52 is the PALEST value where both surfaces clear:\n sheer body text lands 80.0 Lc (was 65.4) and the opaque card 82.9 (was 77.1), and it also restores\n --foreground-strong, which could not reach its 90 target on the cream floor either. Still stone —\n oklch(0.52 0.021 75) is #70675c, a warm grey — and still well clear of the jewels' L36.4. */\n --glass-wash-l: 64%;\n --glass-wash-c-mult: 2;\n /* Fitted for the retired opaque sheet, like the daytime floor above and likewise not the global dark\n pair: moonstone night is a LIGHT floor, so the near-white sheet barely moved it. dE 0.0002. */\n --glass-opaque-l: 52;\n --glass-opaque-c-scale: 0.44;\n /* Static -opaque tier baselines. These flipped polarity with the floor: on the old cream L84.9 they were\n dark warm literals, and on an L52 stone they have to be light or SSR's first paint is dark-on-dark.\n Values are what AutoForeground itself solves for this surface, so the mounted refine is a no-op. */\n --foreground-opaque: oklch(1 0 75);\n --foreground-soft-opaque: oklch(0.9 0.05 75);\n --foreground-strong-opaque: oklch(1 0 75);\n --foreground-ui-opaque: oklch(0.91 0.05 75);\n --muted-foreground-opaque: oklch(0.97 0.02 75);\n /* Chakra needs the SAME treatment opaque gets above, and did not have it: chakra was added after this\n block was written, so it kept the global dark body (L28) and rendered oklch(0.28 0.046 75) = #36250c,\n a dark brown — the one surface where moonstone still read as tobacco rather than stone. Matching\n opaque's pair — now L51, chroma x0.45 → 0.021, landing on #6d6559, the same stone as the opaque\n floor one point above it.\n The hue stays 75 deliberately. Cutting CHROMA is what moves this off the tan family; lightness alone\n does not, and a cool hue would contradict the warm cream light mode keeps at L94. */\n --glass-chakra-l: 51;\n --glass-chakra-c-max: 0.021;\n /* Same reasoning as the -opaque baselines above, and flipped with them: an L51 body needs LIGHT text. */\n --foreground-chakra: oklch(1 0 75);\n --foreground-soft-chakra: oklch(0.9 0.05 75);\n --foreground-strong-chakra: oklch(1 0 75);\n --foreground-ui-chakra: oklch(0.91 0.05 75);\n --muted-foreground-chakra: oklch(0.96 0.03 75);\n}\n\n/* Semantic surfaces (Alert info/success/warning/destructive; destructive Button/Badge) render\n data-glass-tint=\"\", and sonner toasts hit the SAME hues via their data-type attribute\n (destructive pairs with sonner's \"error\") — one block per status, joining the grouped engine\n re-resolution above so --glass-bg / wash re-resolve at the alert/toast. */\n[data-glass-tint=\"info\"],\n[data-sonner-toast][data-type=\"info\"] {\n --glass-tint-h: 255;\n --glass-tint-c: 0.075;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n[data-glass-tint=\"success\"],\n[data-sonner-toast][data-type=\"success\"] {\n --glass-tint-h: 150;\n --glass-tint-c: 0.088;\n --glass-tint-a: 0.18;\n --glass-wash-l: 87%;\n}\n[data-glass-tint=\"warning\"],\n[data-sonner-toast][data-type=\"warning\"] {\n --glass-tint-h: 85;\n --glass-tint-c: 0.063;\n --glass-tint-a: 0.18;\n --glass-wash-l: 84%;\n}\n[data-glass-tint=\"destructive\"],\n[data-sonner-toast][data-type=\"error\"] {\n --glass-tint-h: 25;\n --glass-tint-c: 0.094;\n --glass-tint-a: 0.18;\n --glass-wash-l: 63%;\n}\n/* FRESCOES (muse / sistine / aurora / gloaming): each block's --glass-tint-h/c/a MUST mirror its\n GlassTintSwitcher preset (components/glass-tint-switcher.tsx). The switcher INLINES those onto ,\n shadowing these blocks on any page with the switcher mounted — so these are the static-consumer fallback.\n Keep them identical, or the demo and a static page render different surfaces. (--glass-bg gradients and\n --glass-fg-h are CSS-only — no preset counterpart.) */\n\n/* Muse — the cityscape signature, mode-aware: cool daytime blues in light, the neon night sweep in\n dark, so it tracks the day/night background. Pick the look with the light/dark/system toggle. */\n/* Muse is now ONE palette in both modes: the neon city — cyan → violet → magenta → pink → amber. The\n old day sweep (a pale blue → cyan → teal cityscape) is gone; its hues live on in Aurora, which took\n them over.\n\n What differs between the modes is ALPHA, not colour. A day surface sits on a light page and needs\n more of the gradient to register; night sits on a dark one and needs less before the neon blows out.\n Lightness is shared because these stops are already bright (L 78–85), so they read on either page.\n\n Stop 2's chroma is 0.095, not the 0.17 night shipped with: h290 at L80 has an sRGB ceiling of 0.1052,\n so 0.17 was 162% over and has been browser-gamut-mapped this whole time — which also means it\n rendered differently in Safari (per-channel clamp toward grey) than in Chrome (hue-preserving map).\n 0.095 is 90% of its own ceiling, the same rule the rest of the stops follow. */\n[data-glass-tint=\"muse\"] {\n --glass-tint-h: 230;\n --glass-fg-h: 290; /* text/accent anchor: violet, matching night so the two modes share one identity */\n --glass-tint-c: 0.056;\n --glass-tint-a: 0.16;\n --glass-wash-l: 76%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.32) 0%,\n oklch(80% 0.095 290 / 0.3) 22%,\n oklch(78% 0.2 326 / 0.28) 45%,\n oklch(79% 0.12 350 / 0.28) 70%,\n oklch(84% 0.12 74 / 0.3) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.46) 0%,\n oklch(80% 0.095 290 / 0.44) 22%,\n oklch(78% 0.2 326 / 0.42) 45%,\n oklch(79% 0.12 350 / 0.42) 70%,\n oklch(84% 0.12 74 / 0.44) 100%\n );\n /* Light-mode body: the night anchor's hue (288) at a day-legible lightness. Chroma is 90% of the\n ceiling at L91 h288 (0.0446), because a light surface has very little chroma headroom. */\n --glass-opaque-bg: oklch(91% 0.04 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"muse\"] {\n /* Night: the same neon palette as day, at night's lower alphas (a dark page needs less of the\n gradient before the neon blows out). Only alpha and the solid body differ from the block above. */\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.24) 0%,\n oklch(80% 0.095 290 / 0.22) 22%,\n oklch(78% 0.2 326 / 0.2) 45%,\n oklch(79% 0.12 350 / 0.2) 70%,\n oklch(84% 0.12 74 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(85% 0.1 222 / 0.36) 0%,\n oklch(80% 0.095 290 / 0.34) 22%,\n oklch(78% 0.2 326 / 0.32) 45%,\n oklch(79% 0.12 350 / 0.32) 70%,\n oklch(84% 0.12 74 / 0.34) 100%\n );\n --glass-opaque-bg: oklch(34% 0.12 288);\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Sistine — the signature: a painterly fresco of four jewels (amber → rose → sapphire → aventurine,\n in that order), interpolated `in oklch` so the warm→cool transitions stay vivid instead of\n averaging to grey. Border + crystal + accent pick up the rose base via the tint vars. */\n[data-glass-tint=\"sistine\"] {\n --glass-tint-h: 75; /* surface: mirrors the Sistine switcher preset */\n --glass-fg-h: 18; /* text/accent anchor: crimson (hand-selected) — hue 18 reads as deep red, not the rose at h8 */\n --glass-tint-c: 0.062;\n --glass-tint-a: 0.16;\n --glass-wash-l: 81%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.34) 0%,\n oklch(86% 0.11 8 / 0.3) 33%,\n oklch(86% 0.11 255 / 0.3) 66%,\n oklch(87% 0.11 158 / 0.32) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(90% 0.11 75 / 0.46) 0%,\n oklch(86% 0.11 8 / 0.42) 33%,\n oklch(86% 0.11 255 / 0.42) 66%,\n oklch(87% 0.11 158 / 0.44) 100%\n );\n /* Crystal composes its own background (not --glass-bg), so hand it the fresco explicitly. */\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"sistine\"] {\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.22) 0%,\n oklch(82% 0.11 8 / 0.2) 33%,\n oklch(82% 0.11 255 / 0.2) 66%,\n oklch(83% 0.11 158 / 0.22) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.11 75 / 0.32) 0%,\n oklch(82% 0.11 8 / 0.3) 33%,\n oklch(82% 0.11 255 / 0.3) 66%,\n oklch(83% 0.11 158 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Aurora — northern lights: spring-green → teal → violet, interpolated `in oklch` so the cool sweep\n stays vivid. Border + crystal + accent pick up the teal base via the tint vars.\n DAY was authored at L 90/89/84 — within ~5 L of the L-95 page it composites onto — so after the\n alpha only 2.2 L of separation survived and the bands read as haze rather than bands. Night works\n because its stops sit 60+ L above an L-20 page and land 13.6 L clear of it. The fix is that\n separation, not more colour: day already delivered MORE chroma than night (spread 26/29/19 against\n 14/15/13) and still looked washed out, because colour without lightness structure reads as fog.\n Stops now sit at L 76.3/74.7/71.8, which also buys chroma headroom, so each takes ~90% of its OWN\n hue's sRGB ceiling rather than one shared number — h292 violet was clipped to 65% at L 84 and is\n fully in gamut here. Composited: spread 48/40/38 (was 26/29/19), separation 8.0 L (was 2.2).\n How far this could go is bound by TEXT, not by taste. AutoForeground models a fresco surface from\n the tint wash (--glass-tint-c/-a), never from this gradient literal, so it does not re-band when\n these stops move and the contrast has to hold statically. --foreground-soft (the L-40 heading tier)\n is the weakest pair on a light surface and sets the floor: at a composite of L 84 it measures 59.5\n and fails, so the stops are placed to land no darker than ~L 86. Worst measured here is 63.5.\n Frosted gets its OWN alphas rather than the old ~1.37x boost over --glass-bg: with stops this much\n darker, that multiplier drove the violet composite to L 83.4 and soft to 58.4. These land all three\n near L 86.4 instead. The authored shape is unchanged — green, then teal, then violet darkest. */\n/* Aurora now sweeps Muse Day's hues — blue 235 → cyan 205 → teal-green 155 — instead of the old\n green → cyan → violet. LIGHTNESS is untouched, because it is not a style choice here: the day stops\n sit at L 71.8–76.3 to satisfy the --foreground-soft ≥60 Lc bind described above, and Muse Day's own\n L 91–94 is precisely the band these were moved away from when day read as haze.\n\n CHROMA had to be re-derived, it could not travel with the hues. Each stop is 90% of that hue's OWN\n sRGB ceiling at its lightness, which is the rule the previous stops already followed (all three\n measured exactly 90%). A flat number cannot survive a hue change: the old 0.175 is 90% of green's\n ceiling at L76.3 but 127% of blue's, so carrying it over would have clipped stop 1 outright.\n\n Night is re-derived the same way, which also fixes a pre-existing clip: its violet stop asked 0.13\n against a 0.1068 ceiling at h292/L80 — 122% — so that stop has been gamut-mapped by the browser all\n along. */\n[data-glass-tint=\"aurora\"] {\n --glass-tint-h: 205; /* the sweep's midpoint, was 178 */\n --glass-tint-c: 0.054;\n --glass-tint-a: 0.16;\n --glass-wash-l: 86%;\n --glass-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.4) 0%,\n oklch(74.7% 0.111 205 / 0.38) 50%,\n oklch(71.8% 0.155 155 / 0.38) 100%\n );\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(76.3% 0.124 235 / 0.46) 0%,\n oklch(74.7% 0.111 205 / 0.43) 50%,\n oklch(71.8% 0.155 155 / 0.37) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"aurora\"] {\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.071 235 / 0.22) 0%, oklch(85% 0.127 205 / 0.2) 50%, oklch(80% 0.173 155 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(\n 135deg in oklch,\n oklch(86% 0.071 235 / 0.32) 0%,\n oklch(85% 0.127 205 / 0.3) 50%,\n oklch(80% 0.173 155 / 0.32) 100%\n );\n --glass-crystal-fresco: var(--glass-bg);\n}\n\n/* Gloaming — dusk: gold → rose → indigo, interpolated `in oklch` so the warm→cool fade keeps its\n glow. Night keeps the warm glow; day uses a dusk-rose anchor (the warm hue reads muddy as light-mode text). */\n[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* surface: mirrors the Gloaming switcher preset; text shares it (--glass-fg-h defaults to it) */\n --glass-tint-c: 0.089;\n --glass-tint-a: 0.16;\n --glass-wash-l: 64%;\n --glass-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.32) 0%, oklch(84% 0.14 350 / 0.3) 50%, oklch(78% 0.13 278 / 0.3) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(89% 0.12 62 / 0.44) 0%, oklch(84% 0.14 350 / 0.42) 50%, oklch(78% 0.13 278 / 0.42) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n.dark[data-glass-tint=\"gloaming\"] {\n --glass-tint-h: 32; /* night keeps the warm glow; day uses the dusk-rose above */\n --glass-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.22) 0%, oklch(80% 0.14 350 / 0.2) 50%, oklch(74% 0.13 278 / 0.22) 100%);\n --glass-frosted-bg: linear-gradient(135deg in oklch, oklch(86% 0.12 62 / 0.32) 0%, oklch(80% 0.14 350 / 0.3) 50%, oklch(74% 0.13 278 / 0.32) 100%);\n --glass-crystal-fresco: var(--glass-bg);\n}\n/* Sonner Toast Notifications - Glass Effect Overrides */\n[data-sonner-toast] {\n /* Glass gradient layered over a mostly-opaque surface so toast text stays legible.\n Lower the popover % to make toasts more see-through, raise it to make them more solid. */\n background: var(--glass-bg), color-mix(in oklch, var(--popover) 78%, transparent) !important;\n backdrop-filter: blur(var(--blur)) saturate(180%) !important;\n border: 1px solid var(--glass-border) !important;\n box-shadow: var(--glass-shadow-lg), var(--glow-layer, 0 0 0 0 transparent) !important;\n color: var(--foreground) !important;\n}\n\n/* Status toast borders — derived from the toast's own status tint (the merged status blocks above set\n --glass-tint-h at the toast) instead of four hardcoded tailwind-500 literals; within ~0.1 L of the\n originals on a 1px 30%-alpha border. NOT bare [data-type]: default/loading toasts keep the plain\n glass border. */\n[data-sonner-toast][data-type=\"success\"],\n[data-sonner-toast][data-type=\"error\"],\n[data-sonner-toast][data-type=\"warning\"],\n[data-sonner-toast][data-type=\"info\"] {\n border-color: oklch(0.7 0.18 var(--glass-tint-h) / 0.3) !important;\n}\n\n/* Toast description text */\n[data-sonner-toast] [data-description] {\n color: var(--muted-foreground) !important;\n}\n\n/* Toast action / cancel / close buttons — one glass recipe (the hover fade now covers all three;\n previously only the action button transitioned). Cancel dims to muted below. */\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]) {\n background: var(--glass-bg) !important;\n backdrop-filter: blur(var(--blur-sm)) !important;\n border: 1px solid var(--glass-border) !important;\n color: var(--foreground) !important;\n transition: opacity 0.2s ease !important;\n}\n\n[data-sonner-toast] [data-cancel] {\n color: var(--muted-foreground) !important;\n}\n\n[data-sonner-toast] :is([data-button], [data-cancel], [data-close-button]):hover {\n opacity: 0.9 !important;\n}\n", "type": "registry:style", "target": "app/globals.css" } diff --git a/registry/theme/globals.css b/registry/theme/globals.css index 5e6a121..96ac735 100644 --- a/registry/theme/globals.css +++ b/registry/theme/globals.css @@ -1652,6 +1652,19 @@ --srf-bg-color: var(--glass-opaque-bg); --srf-filter: none; --srf-shadow: var(--glass-shadow); +} +/* --glass-solid-a CANNOT ride in the :where() block above. :where() is specificity ZERO by design (so a + page style never beats an explicit material), and every other declaration up there survives that + because nothing else declares them — they are material SLOTS with no competing value. This one is + different: tokens.css declares --glass-solid-a on :root, which is (0,1,0), so the :where() version + lost every time and the opaque page never actually got its solid floor. Measured: --glass-solid-a + resolved to 0.65 on under [data-glass="opaque"], identical to every other page style. + The visible symptom is any VEILED surface on an opaque page staying translucent while the solid + panels around it go opaque — menus, toasts and popovers, which is exactly what the block above says + this is for. A bare attribute selector is (0,1,0), which ties :root and wins on source order because + globals.css imports styles.css after tokens.css. It is a plain literal, so it simply inherits into + the tint / toast scopes and needs none of their re-resolution. */ +[data-glass="opaque"] { --glass-solid-a: 1; } /* Opaque page: element-level touches for ADAPTIVE surfaces only (`.glass` with no explicit material —