diff --git a/CLAUDE.md b/CLAUDE.md
index 4cdc3356..ca5c7eaf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -172,9 +172,17 @@ promoted to `dev` in batches. While it is in progress:
(`panel`, `btn`, `form-input`, `badge`, `animate__*`, `screen_loader`, `main-section`),
plus `pilotui/toast` and `useAppStore` from `pilotui/store`. Un-migrated screens render
inside the new shell and keep working.
-- **Dark mode is light-only on migrated surfaces.** The theme switcher stays and
- `:root.dark` is scaffolded but empty, so toggling dark leaves migrated screens light while
- pilotui ones go dark. Deliberate; dark lands as its own milestone.
+- **Dark mode is real on `st-` surfaces.** The theme is applied as
+ `data-theme="light" | "dark"` on ``; both Tailwind builds use
+ `darkMode: ['selector', '[data-theme="dark"]']`, and `@nuxtjs/color-mode` owns the
+ preference (Light / Dark / System, `subturtle:theme`, pre-paint script, no flash). The
+ control is `StThemeSwitcher` in the topbar — there is no Appearance row in the profile
+ menu. Un-migrated pilotui screens keep their own `dark:` behaviour; the module also keeps
+ writing the `.dark` class and `plugins/theme.client.ts` mirrors the preference into
+ pilotui's store, so the two never disagree, but they are not styled to match and will not
+ be until each screen migrates.
+ ⚠️ `ui/src/styles/theme-tokens.css` still holds the EARLIER handoff's palette. The design
+ system's own `subturtle-theme.css` supersedes it wholesale — see the banner in that file.
- `subturtle-ui` is a `link:../ui` dependency whose `dist/` is not committed, so the
frontend's `postinstall` builds it. Any context that installs the frontend needs `ui/`
present — the Dockerfile copies it in.
diff --git a/frontend/components/Leitner/LeitnerPhrasePicker.vue b/frontend/components/Leitner/LeitnerPhrasePicker.vue
index b4fb07c6..bf4df394 100644
--- a/frontend/components/Leitner/LeitnerPhrasePicker.vue
+++ b/frontend/components/Leitner/LeitnerPhrasePicker.vue
@@ -1,324 +1,285 @@
-
-
-
-
-
-
+
diff --git a/frontend/composables/useAppTheme.ts b/frontend/composables/useAppTheme.ts
new file mode 100644
index 00000000..57a3664e
--- /dev/null
+++ b/frontend/composables/useAppTheme.ts
@@ -0,0 +1,32 @@
+export type AppTheme = 'light' | 'dark' | 'system';
+
+/**
+ * The app's view of the theme switch: three states, with `system` following the OS.
+ *
+ * StThemeSwitcher binds to `theme` and is passed `persist-key=""` / `:apply="false"`, so
+ * @nuxtjs/color-mode stays the single writer of both `subturtle:theme` and the `data-theme`
+ * attribute.
+ *
+ * Thin on purpose. `@nuxtjs/color-mode` already persists the preference, resolves `system` from
+ * `prefers-color-scheme`, keeps following it live via its own matchMedia listener, and stamps the
+ * `data-theme` attribute on before first paint. The side effects that are ours — the transition guard and the
+ * pilotui mirror — are registered once in plugins/theme.client.ts, not here, so calling this from
+ * several components is free.
+ *
+ * `theme` is what a switch binds to (the user's choice, which may be `system`); `resolved` is what
+ * is actually on screen, for anything that has to branch on the real palette.
+ */
+export function useAppTheme() {
+ const colorMode = useColorMode();
+
+ const theme = computed({
+ get: () => colorMode.preference as AppTheme,
+ set: (next) => {
+ colorMode.preference = next;
+ },
+ });
+
+ const resolved = computed<'light' | 'dark'>(() => (colorMode.value === 'dark' ? 'dark' : 'light'));
+
+ return { theme, resolved };
+}
diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue
index 7ad2e0d5..cb79c3bf 100644
--- a/frontend/layouts/default.vue
+++ b/frontend/layouts/default.vue
@@ -1,9 +1,10 @@
+ properties directly. The alpha is scaled off --blob-alpha (5% light / 3% dark) so
+ these follow the same "blobs drop in dark" rule as the app shell's, at the stronger
+ weight this screen is drawn at. -->
+
+
diff --git a/frontend/plugins/theme.client.ts b/frontend/plugins/theme.client.ts
new file mode 100644
index 00000000..7b57778f
--- /dev/null
+++ b/frontend/plugins/theme.client.ts
@@ -0,0 +1,49 @@
+import { useAppStore } from 'pilotui/store';
+
+/**
+ * Session-wide theme side effects. `@nuxtjs/color-mode` owns the preference and the
+ * `light`/`dark` class on ; this plugin adds the two things it does not do.
+ *
+ * 1. Suppress transitions for the frame in which the palette swaps, so the page repaints in the
+ * new theme instead of cross-fading every colour independently.
+ * 2. Mirror the preference into pilotui's app store, so the screens still on pilotui follow the
+ * same switch. They keep their own `dark:` styling — this only keeps the two in sync.
+ *
+ * Both live here rather than in a composable because they must be registered exactly once, before
+ * any component can flip the theme.
+ */
+export default defineNuxtPlugin((nuxtApp) => {
+ const colorMode = useColorMode();
+
+ /**
+ * `flush: 'sync'` matters. The colour-mode plugin registers its own watcher on the same source
+ * to swap the class on ; ours has to have already added `theme-switching` by the time
+ * that runs, and a sync watcher is the only flush that is guaranteed to. It also covers both
+ * ways the resolved value can change — a click on the Appearance row, and an OS-level
+ * `prefers-color-scheme` flip while the preference is `system` (which the module's own
+ * matchMedia listener applies directly to `value`, never touching `preference`).
+ */
+ watch(
+ () => colorMode.value,
+ () => {
+ const root = document.documentElement;
+ root.classList.add('theme-switching');
+ // Force a style recalculation so the suppression is in effect for the swap itself
+ // rather than being coalesced with the removal below.
+ void window.getComputedStyle(root).opacity;
+ requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-switching')));
+ },
+ { flush: 'sync' }
+ );
+
+ // Pinia is ready by app:mounted, and so is pilotui's — before that, writing to the store
+ // races with 's own restore-from-localStorage on mount and the value can be clobbered.
+ nuxtApp.hook('app:mounted', () => {
+ const appStore = useAppStore();
+ // Watches the RESOLVED value as well as the preference. pilotui resolves `system` against
+ // the media query itself, at the moment toggleTheme() is called — so an OS-level flip, which
+ // leaves `preference` on `system` and only moves `value`, has to re-poke it or the pilotui
+ // screens would stay on the old palette while the `st-` surfaces flipped.
+ watch([() => colorMode.preference, () => colorMode.value], () => appStore.toggleTheme(colorMode.preference), { immediate: true });
+ });
+});
diff --git a/frontend/stores/profile.ts b/frontend/stores/profile.ts
index b21ae8bd..96c15f8c 100644
--- a/frontend/stores/profile.ts
+++ b/frontend/stores/profile.ts
@@ -174,9 +174,11 @@ export const useProfileStore = defineStore('profile', () => {
const gPicture = profile?.gPicture;
const knownFailed = !!(userId && gPicture && isPictureMarkedFailed(userId, gPicture));
- // If we already know this URL is broken, strip it before exposing the profile
- // to Vue — that way ProfileButton never renders the doomed .
- userDetail.value = knownFailed ? { ...profile, gPicture: '' } : profile;
+ // `knownFailed` means the CANVAS CACHE of this URL failed before, not that the URL
+ // is undisplayable — see the note in the catch below. So the profile is exposed
+ // with gPicture intact either way, and only the re-encode is skipped; StAvatar
+ // falls back to initials if the itself actually errors.
+ userDetail.value = profile;
if (userId && gPicture && !knownFailed) {
const cached = readCachedPicture(userId);
@@ -187,13 +189,18 @@ export const useProfileStore = defineStore('profile', () => {
// UI falls back to gPicture URL until the encode completes.
profilePictureBase64.value = '';
downloadAndCachePicture(userId, gPicture).catch(() => {
- // The CORS image fetch failed — remember this URL is broken so we
- // don't keep retrying, and drop gPicture from local state so
- // renderers fall through to the placeholder.
+ // The caching fetch failed — remember that, so we don't re-attempt the
+ // encode on every load.
+ //
+ // But do NOT strip gPicture. This path draws the image to a canvas and
+ // calls toDataURL(), which is the only reason it needs `crossOrigin`,
+ // and so the usual failure here is a CORS one. A plain needs
+ // no CORS whatsoever — the browser renders that URL fine. Dropping
+ // gPicture on a CORS error therefore hid a perfectly displayable
+ // avatar behind the initials fallback. A genuinely dead URL is caught
+ // where it belongs, by the itself: StAvatar falls back to
+ // initials on its error event.
markPictureFailed(userId, gPicture);
- if (userDetail.value && userDetail.value.gPicture === gPicture) {
- userDetail.value = { ...userDetail.value, gPicture: '' };
- }
});
}
} else {
diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs
index 8e4b31a0..35dab2d7 100644
--- a/frontend/tailwind.config.cjs
+++ b/frontend/tailwind.config.cjs
@@ -17,7 +17,7 @@ module.exports = {
'./composables/**/*.{js,ts}',
'./nuxt.config.{js,ts}',
],
- darkMode: 'class',
+ darkMode: ['selector', '[data-theme="dark"]'],
theme: {
container: {
center: true,
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 32b76673..4140fcf9 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1346,6 +1346,33 @@
unimport "^4.1.2"
untyped "^2.0.0"
+"@nuxt/kit@^4.4.6":
+ version "4.5.2"
+ resolved "https://registry.yarnpkg.com/@nuxt/kit/-/kit-4.5.2.tgz#fa338b169d14d9e66f973ded551d2376edb72398"
+ integrity sha512-l66LU9DcJYjmNwqwAj2I5UGRrUbnG2DOKGChnN70zIGtn0eq/z87gi/FRgha6eMb9/FmB1PFHgtx6PWVml1C2Q==
+ dependencies:
+ c12 "^3.3.4"
+ consola "^3.4.2"
+ defu "^6.1.7"
+ destr "^2.0.5"
+ errx "^0.1.2"
+ exsolve "^1.1.1"
+ ignore "^7.0.6"
+ jiti "^2.7.0"
+ klona "^2.0.6"
+ mlly "^1.8.2"
+ nostics "^1.2.0"
+ ohash "^2.0.11"
+ pathe "^2.0.3"
+ pkg-types "^2.3.1"
+ rc9 "^3.0.1"
+ scule "^1.3.0"
+ tinyglobby "^0.2.17"
+ ufo "^1.6.4"
+ unctx "^3.0.0"
+ untyped "^2.0.0"
+ verkit "^0.3.1"
+
"@nuxt/schema@3.13.2":
version "3.13.2"
resolved "https://registry.npmjs.org/@nuxt/schema/-/schema-3.13.2.tgz"
@@ -1424,6 +1451,17 @@
vite-plugin-checker "^0.8.0"
vue-bundle-renderer "^2.1.0"
+"@nuxtjs/color-mode@^4.0.1":
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/@nuxtjs/color-mode/-/color-mode-4.0.1.tgz#fd06ba8dac561278ec62261267fce95651260bf6"
+ integrity sha512-eiA7hWXi5zNHaYKyJFCGF6i0wFZtuvR7KDXZ6jiSvwxjCpRFwphrw0MOSmNfArTSSsT1wpW+/2H92cejeVfUlg==
+ dependencies:
+ "@nuxt/kit" "^4.4.6"
+ exsolve "^1.0.8"
+ pathe "^2.0.3"
+ pkg-types "^2.3.1"
+ semver "^7.8.1"
+
"@nuxtjs/i18n@^8.0.0-beta.9":
version "8.5.6"
resolved "https://registry.npmjs.org/@nuxtjs/i18n/-/i18n-8.5.6.tgz"
@@ -2898,6 +2936,11 @@ acorn@^8.14.0, acorn@^8.5.0, acorn@^8.6.0, acorn@^8.8.2, acorn@^8.9.0:
resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz"
integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==
+acorn@^8.16.0:
+ version "8.18.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
+ integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
+
agent-base@9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-9.0.0.tgz#ec9efb08314e1e75b0852d74aabf9a387f99834e"
@@ -3350,6 +3393,24 @@ c12@^3.0.2:
pkg-types "^2.0.0"
rc9 "^2.1.2"
+c12@^3.3.4:
+ version "3.3.4"
+ resolved "https://registry.yarnpkg.com/c12/-/c12-3.3.4.tgz#1253a5faf8b61244884d42459b4a6412571fe9f3"
+ integrity sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==
+ dependencies:
+ chokidar "^5.0.0"
+ confbox "^0.2.4"
+ defu "^6.1.6"
+ dotenv "^17.3.1"
+ exsolve "^1.0.8"
+ giget "^3.2.0"
+ jiti "^2.6.1"
+ ohash "^2.0.11"
+ pathe "^2.0.3"
+ perfect-debounce "^2.1.0"
+ pkg-types "^2.3.0"
+ rc9 "^3.0.1"
+
cac@^6.7.14:
version "6.7.14"
resolved "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz"
@@ -3496,6 +3557,13 @@ chokidar@^4.0.3:
dependencies:
readdirp "^4.0.1"
+chokidar@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-5.0.0.tgz#949c126a9238a80792be9a0265934f098af369a5"
+ integrity sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==
+ dependencies:
+ readdirp "^5.0.0"
+
chownr@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz"
@@ -3732,6 +3800,11 @@ confbox@^0.2.1:
resolved "https://registry.npmjs.org/confbox/-/confbox-0.2.1.tgz"
integrity sha512-hkT3yDPFbs95mNCy1+7qNKC6Pro+/ibzYxtM2iqEigpf0sVw+bg4Zh9/snjsBcf990vfIsg5+1U7VyiyBb3etg==
+confbox@^0.2.4:
+ version "0.2.4"
+ resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.4.tgz#592e7be71f882a4a874e3c88f0ac1ef6f7da1ce5"
+ integrity sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==
+
config-chain@^1.1.11, config-chain@^1.1.13:
version "1.1.13"
resolved "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz"
@@ -4196,7 +4269,7 @@ define-properties@^1.2.1:
has-property-descriptors "^1.0.0"
object-keys "^1.1.1"
-defu@^6.1.2, defu@^6.1.4, defu@^6.1.6:
+defu@^6.1.2, defu@^6.1.4, defu@^6.1.6, defu@^6.1.7:
version "6.1.7"
resolved "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz"
integrity sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==
@@ -4352,6 +4425,11 @@ dotenv@^16.4.5, dotenv@^16.4.7:
resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz"
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==
+dotenv@^17.3.1:
+ version "17.4.2"
+ resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034"
+ integrity sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==
+
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"
@@ -4498,6 +4576,11 @@ errx@^0.1.0:
resolved "https://registry.npmjs.org/errx/-/errx-0.1.0.tgz"
integrity sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==
+errx@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/errx/-/errx-0.1.2.tgz#cd870178afe9072700d8486af4eb3c358892f40c"
+ integrity sha512-chfpPHmCerdo/rXr/nNvPZRkV4WwDRwzwnsJ0Uzz3tVi8Z41tDctRjduYy1138ii77AFlts1qvWtX3g/Acg91Q==
+
es-define-property@^1.0.0, es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz"
@@ -4805,6 +4888,11 @@ exsolve@^1.0.0, exsolve@^1.0.1, exsolve@^1.0.2, exsolve@^1.0.4:
resolved "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz"
integrity sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw==
+exsolve@^1.0.8, exsolve@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.1.1.tgz#c055418255459b6ecde4e59de0060a3e97bc7572"
+ integrity sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==
+
extend@^3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz"
@@ -5157,6 +5245,11 @@ giget@^2.0.0:
nypm "^0.6.0"
pathe "^2.0.3"
+giget@^3.2.0:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/giget/-/giget-3.3.1.tgz#4a4e610cd112e5dc478c6035986fdc19c76dad73"
+ integrity sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==
+
git-log-parser@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/git-log-parser/-/git-log-parser-1.2.1.tgz#44355787b37af7560dcc4ddc01cb53b5d139cc28"
@@ -5509,6 +5602,11 @@ ignore@^7.0.3:
resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz"
integrity sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==
+ignore@^7.0.6:
+ version "7.0.8"
+ resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.8.tgz#84d8466899958458ee30b4190c839ee1446cc88d"
+ integrity sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==
+
image-meta@^0.2.1:
version "0.2.1"
resolved "https://registry.npmjs.org/image-meta/-/image-meta-0.2.1.tgz"
@@ -5997,6 +6095,11 @@ jiti@^2.1.2, jiti@^2.4.1, jiti@^2.4.2:
resolved "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz"
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
+jiti@^2.6.1, jiti@^2.7.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
+ integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
+
js-beautify@^1.14.9:
version "1.15.4"
resolved "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz"
@@ -6837,6 +6940,16 @@ mlly@^1.2.0, mlly@^1.3.0, mlly@^1.6.1, mlly@^1.7.1, mlly@^1.7.3, mlly@^1.7.4:
pkg-types "^1.3.0"
ufo "^1.5.4"
+mlly@^1.8.2:
+ version "1.8.2"
+ resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.2.tgz#e7f7919a82d13b174405613117249a3f449d78bb"
+ integrity sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==
+ dependencies:
+ acorn "^8.16.0"
+ pathe "^2.0.3"
+ pkg-types "^1.3.1"
+ ufo "^1.6.3"
+
mrmime@^2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz"
@@ -7122,6 +7235,11 @@ normalize-url@^9.0.0:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-9.0.1.tgz#c8fe3b4b045074dbe872a357ebc4b7e19d7fb043"
integrity sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==
+nostics@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/nostics/-/nostics-1.2.0.tgz#1362df6de2ca8456b521914501abf6c52c4d7fb5"
+ integrity sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==
+
npm-audit-report@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/npm-audit-report/-/npm-audit-report-7.0.0.tgz#c384ac4afede55f21b30778202ad568e54644c35"
@@ -7819,6 +7937,11 @@ perfect-debounce@^1.0.0:
resolved "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz"
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
+perfect-debounce@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz#e7078e38f231cb191855c3136a4423aef725d261"
+ integrity sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==
+
perfect-scrollbar@^1.5.5:
version "1.5.6"
resolved "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.6.tgz"
@@ -7922,6 +8045,15 @@ pkg-types@^2.0.0, pkg-types@^2.0.1, pkg-types@^2.1.0:
exsolve "^1.0.1"
pathe "^2.0.3"
+pkg-types@^2.3.0, pkg-types@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-2.3.1.tgz#fa27ed0940efcf40bba453b0e5cab41217b0d442"
+ integrity sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==
+ dependencies:
+ confbox "^0.2.4"
+ exsolve "^1.0.8"
+ pathe "^2.0.3"
+
playwright-core@1.54.1:
version "1.54.1"
resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz"
@@ -8635,7 +8767,7 @@ rc9@^2.1.2:
defu "^6.1.4"
destr "^2.0.3"
-rc9@^3.0.0:
+rc9@^3.0.0, rc9@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz"
integrity sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==
@@ -8758,6 +8890,11 @@ readdirp@^4.0.1:
resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
+readdirp@^5.0.0:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-5.1.1.tgz#520bca06f9d1ae1b96cc0800dbe84b983d19422c"
+ integrity sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==
+
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz"
@@ -9162,6 +9299,11 @@ semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.6.3, semve
resolved "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz"
integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==
+semver@^7.8.1:
+ version "7.8.5"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
+ integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
+
send@0.19.0:
version "0.19.0"
resolved "https://registry.npmjs.org/send/-/send-0.19.0.tgz"
@@ -9995,7 +10137,7 @@ tinyglobby@^0.2.10, tinyglobby@^0.2.11:
fdir "^6.4.3"
picomatch "^4.0.2"
-tinyglobby@^0.2.12, tinyglobby@^0.2.14:
+tinyglobby@^0.2.12, tinyglobby@^0.2.14, tinyglobby@^0.2.17:
version "0.2.17"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
@@ -10169,6 +10311,11 @@ ufo@^1.1.2, ufo@^1.3.1, ufo@^1.5.4:
resolved "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz"
integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==
+ufo@^1.6.3, ufo@^1.6.4:
+ version "1.6.4"
+ resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.4.tgz#7a8fb875fcc6382d2c7d0b3692738b0500a92467"
+ integrity sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==
+
uglify-js@^3.1.4:
version "3.19.3"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
@@ -10194,6 +10341,11 @@ unctx@^2.3.1, unctx@^2.4.1:
magic-string "^0.30.17"
unplugin "^2.1.0"
+unctx@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/unctx/-/unctx-3.0.1.tgz#16dc56fc256a2bf4df485d9f38866634109058a1"
+ integrity sha512-5RAt2etv7g362RXyd33R82gm9u/kbtQlpoaOs9Bgm4E32GRMJ0xjbZyvdxUTmiuHk3V1IQb1aUNrp5IWY+JaWw==
+
undici-types@~6.20.0:
version "6.20.0"
resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz"
@@ -10489,6 +10641,11 @@ vee-validate@^4.15.0:
"@vue/devtools-api" "^7.5.2"
type-fest "^4.8.3"
+verkit@^0.3.1:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/verkit/-/verkit-0.3.2.tgz#0511ea63310551b62a20c010aa820350c5c350c6"
+ integrity sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==
+
vite-hot-client@^0.2.4:
version "0.2.4"
resolved "https://registry.npmjs.org/vite-hot-client/-/vite-hot-client-0.2.4.tgz"
diff --git a/ui/README.md b/ui/README.md
index 9f45d85f..b0fc6a6b 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -72,9 +72,29 @@ composes them as `rgb(var(--token) / )`. That is what makes opacity
(`st-bg-primary/5`, the ambient background blobs) and what a dark theme will hook into. Write raw
CSS against them as `rgb(var(--rose-500))`.
-`:root.dark` is scaffolded and empty. The design system ships light values only, and the product
-keeps its theme switcher, so dark currently leaves anything built on this library in its light
-palette. Adding dark means redefining the semantic aliases there — no component should change.
+`src/styles/theme-tokens.css` is the dark half, imported straight after `tokens.css`. It re-points
+the **same token names** under `html.dark`, so a component already written against
+`var(--surface-card)` / `var(--text-body)` / `var(--ink-100)` themes itself with no edits — that is
+the whole mechanism, and it is why nothing in `src/` carries a `dark:` variant.
+
+Two flips in that file are load-bearing, and both look wrong until you know the role they protect:
+
+- **The `--ink-*` ramp inverts** — `--ink-50` is the darkest step in dark. Components use `ink-100`
+ as a hover wash and `ink-150`/`ink-200` as hairlines; inverting is what preserves those roles.
+ The corollary is that `ink-800`/`ink-900` can no longer be used as a *dark fill* (it would become
+ a light fill); `bg-inverse` + `text-page` is the pair for that.
+- **`--rose-700` / `--jade-700` (and the `-600` steps) become light tints** — they are used as
+ "text on a soft brand tint", so they flip to stay legible.
+
+`--white` is deliberately *not* re-pointed. It means "ink on a rose CTA". Never use it as a
+surface; use `--surface-card`.
+
+The theme is applied as **`data-theme="light" | "dark"` on ``** (both Tailwind builds use
+`darkMode: ['selector', '[data-theme="dark"]']`).
+
+Consumers must also add `class="theme-switching"` to `` for the one frame around a change —
+the stylesheet kills transitions while it is set, otherwise the whole page cross-fades. See the
+dashboard's `plugins/theme.client.ts`.
### Icons
@@ -102,13 +122,45 @@ yarn dev # rebuild on change, for a linked consumer
| Group | |
| --- | --- |
-| Shell | `StAppShell`, `StSidebarNav` |
-| Elements | `StButton`, `StIconButton`, `StCard`, `StBadge`, `StAvatar`, `StEmptyState`, `StSkeleton`, `StIcon` |
+| Shell | `StAppShell`, `StSidebarNav`, `StProfileMenu` |
+| Elements | `StButton`, `StIconButton`, `StCard`, `StBadge`, `StAvatar`, `StEmptyState`, `StSkeleton`, `StThemeSwitcher`, `StIcon` |
| Brand | `StStatTile`, `StBundleCard` |
-This is the set the app shell and the Progress screen need. The rest of the design system
-(`Input`, `Tag`, `Switch`, `ProgressBar`, `SegmentedControl`, `Tabs`, `Modal`, `Toast`,
-`PhraseCard`, `Flashcard`, `LevelPip`, `PlanCard`) lands as each remaining screen is migrated.
+This is the set the app shell, the Progress screen and the Login screen need. The rest of the
+design system (`Input`, `Tag`, `Switch`, `ProgressBar`, `SegmentedControl`, `Tabs`, `Modal`,
+`Toast`, `PhraseCard`, `Flashcard`, `LevelPip`, `PlanCard`) lands as each remaining screen is
+migrated.
+
+### `StThemeSwitcher`
+
+One round icon button that cycles Light → Dark → System. It can run standalone — persisting to
+`persistKey` and writing `data-theme` itself — or controlled via `v-model` with `persist-key=""`
+and `:apply="false"`, which is what a host that already owns the theme (the dashboard, through
+`@nuxtjs/color-mode`) must pass so the two do not both write the attribute.
+
+`labels.aria` and `labels.resolved` are **formatters**, not patterns. Handing a translated pattern
+like `t('theme.aria')` over instead looks correct and silently isn't: vue-i18n interpolates the
+placeholders on the way out, so `{current}` / `{next}` arrive already blanked.
+
+Positioning it is done by a **wrapper element**, not by passing layout classes down. Its root
+carries `st-relative`, and `dist/style.css` loads after the host's own Tailwind, so an `absolute`
+passed as a fall-through class loses the specificity tie.
+
+### `StProfileMenu`
+
+The account dropdown, shared by the dashboard topbar and the extension popup. Two things about it
+are not stylistic preferences:
+
+- **The panel is teleported to `` and positioned `fixed`** against the trigger rect,
+ re-placed on `resize` and on **capture-phase** `scroll` (so scrolling `StAppShell`'s inner
+ `` counts). The topbar sets `backdrop-filter`, which bleeds its blur under an
+ absolutely-positioned descendant and washes the panel out. The design prototype hit this and
+ fixes it the same way.
+There is no Appearance row: the design system ships `StThemeSwitcher` as a dedicated topbar
+control, so the menu keeps its four rows.
+
+The component holds no router, store or i18n coupling — `items` is a plain array carrying the
+caller's own handlers, and every visible string comes in through `labels`.
Three things were deliberately added rather than ported, because the source components are React
files styled entirely with inline style objects and cannot express them: nav-item hover states,
diff --git a/ui/scripts/build-icons.mjs b/ui/scripts/build-icons.mjs
index f53d1d29..75401f28 100644
--- a/ui/scripts/build-icons.mjs
+++ b/ui/scripts/build-icons.mjs
@@ -18,36 +18,58 @@ import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
-/** Every icon the shell, the Progress screen and the Login screen use. Keep sorted by set, then name. */
+/** Every icon the shell, the Progress screen, the Login screen and the profile menu use. Keep sorted by set, then name. */
const ICONS = [
'logos:google-icon',
'solar:add-circle-bold',
+ 'solar:alt-arrow-down-linear',
'solar:alt-arrow-left-linear',
'solar:alt-arrow-right-linear',
'solar:arrow-down-bold',
'solar:arrow-right-bold',
'solar:arrow-right-linear',
'solar:arrow-up-bold',
+ 'solar:bolt-linear',
'solar:bookmark-bold-duotone',
+ 'solar:calendar-minimalistic-linear',
+ 'solar:card-2-bold-duotone',
'solar:chart-2-bold-duotone',
+ 'solar:check-circle-bold',
+ 'solar:check-circle-bold-duotone',
'solar:clock-circle-bold',
+ 'solar:clock-circle-bold-duotone',
+ 'solar:close-circle-bold',
'solar:crown-bold',
'solar:crown-bold-duotone',
+ 'solar:crown-linear',
'solar:danger-triangle-bold',
+ 'solar:diskette-bold',
'solar:documents-bold-duotone',
'solar:download-minimalistic-bold',
'solar:fire-bold',
'solar:fire-bold-duotone',
'solar:hamburger-menu-linear',
'solar:history-2-bold-duotone',
+ 'solar:inbox-in-bold-duotone',
'solar:layers-minimalistic-bold-duotone',
'solar:lock-keyhole-minimalistic-bold-duotone',
+ 'solar:logout-2-linear',
+ 'solar:magnifer-linear',
'solar:microphone-3-bold-duotone',
+ 'solar:monitor-bold-duotone',
+ 'solar:moon-bold-duotone',
'solar:moon-sleep-bold-duotone',
+ 'solar:notebook-bold',
'solar:notebook-bold-duotone',
'solar:play-bold',
+ 'solar:refresh-bold',
+ 'solar:restart-bold',
'solar:rocket-2-bold-duotone',
'solar:settings-bold-duotone',
+ 'solar:settings-linear',
+ 'solar:sun-2-bold-duotone',
+ 'solar:tuning-2-bold',
+ 'solar:user-linear',
];
const setCache = new Map();
diff --git a/ui/src/brand/StBundleCard.vue b/ui/src/brand/StBundleCard.vue
index 7327c2f7..ccfdd5d4 100644
--- a/ui/src/brand/StBundleCard.vue
+++ b/ui/src/brand/StBundleCard.vue
@@ -22,8 +22,8 @@
-->
{{ sourceLang }}
@@ -35,7 +35,7 @@
v-if="!$slots.cover"
name="solar:notebook-bold-duotone"
:size="40"
- class="st-text-white/45"
+ class="st-text-on-brand/45"
/>
+
+
+
diff --git a/ui/src/shell/StSidebarNav.vue b/ui/src/shell/StSidebarNav.vue
index 7ab7f8d5..1764aa40 100644
--- a/ui/src/shell/StSidebarNav.vue
+++ b/ui/src/shell/StSidebarNav.vue
@@ -102,7 +102,8 @@
padding: 6px 11px;
border-radius: var(--radius-sm);
background: rgb(var(--ink-950));
- color: rgb(var(--white));
+ /* Literal, not var(--white): the dark layer redeclares that as the card neutral. */
+ color: #fff;
font-size: var(--text-xs);
font-weight: 700;
box-shadow: var(--shadow-md);
diff --git a/ui/src/styles/index.css b/ui/src/styles/index.css
index f6749103..c93ca56f 100644
--- a/ui/src/styles/index.css
+++ b/ui/src/styles/index.css
@@ -1,6 +1,8 @@
/* Google Fonts must be the first statement in the file, or browsers drop it. */
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,600&family=JetBrains+Mono:wght@400;500;600&display=swap');
@import './tokens.css';
+/* MUST come after tokens.css — it re-points the same names under `html[data-theme='dark']`. */
+@import './theme-tokens.css';
@tailwind base;
@tailwind components;
diff --git a/ui/src/styles/theme-tokens.css b/ui/src/styles/theme-tokens.css
new file mode 100644
index 00000000..aec1f2a4
--- /dev/null
+++ b/ui/src/styles/theme-tokens.css
@@ -0,0 +1,193 @@
+/* ============================================================
+ Subturtle — theme layer (dark).
+
+ ⚠️ SUPERSEDED, AND STILL HERE ON PURPOSE.
+
+ The design system now ships `subturtle-theme.css` (its own tokens/dark.css plus three
+ patches), and THAT is the file this one has to be replaced by, wholesale. It was not
+ available when this was written, so the palette below is the earlier profile-menu
+ handoff's. The two genuinely disagree, so do not reconcile them by hand:
+
+ - ramps here invert step-for-step; there they invert around their MID-step, with
+ --rose-500 / --jade-500 keeping their exact brand values
+ - --paper #161219 here, #15111a there; --surface-card #211b28 here, #1e1826 there
+ - --white is NOT re-pointed here, but IS #1e1826 there ("the card neutral")
+
+ That last one reverses the meaning of --white, so the literal-white audit already done
+ in the components (`st-text-on-brand`, see tailwind.config.cjs) is correct for BOTH and
+ should survive the swap. Everything else in this file is placeholder-accurate only.
+
+ Loaded from index.css
+ immediately after tokens.css, which is the whole mechanism: this file re-points the
+ SAME token names under `html[data-theme='dark']`, so every component already styled with
+ var(--surface-card) / var(--text-body) / var(--ink-100) themes itself with no edits.
+
+ ONE DELIBERATE DIFFERENCE FROM THE HANDOFF FILE — and it is not optional. The handoff
+ writes colours as hex; tokens.css stores them as space-separated RGB channels so
+ Tailwind can compose `rgb(var(--token) / )`. Shipping the hex verbatim
+ would emit `rgb(#211b28 / 1)` and silently break every `st-` colour utility in dark.
+ The VALUES below are the handoff's, converted channel-for-channel; the two tokens
+ tokens.css documents as finished colours (--ring-focus) or as caller-alpha channels
+ (--surface-overlay) keep their documented form.
+
+ The handoff's translucent tints (rgba(249,30,90,.18) and friends) cannot survive that
+ conversion — a channel token has no room for its own alpha. They are pre-composited
+ against --surface-card (#211b28), the surface they overwhelmingly appear on. The
+ original rgba is kept in a comment beside each so the intent is auditable.
+
+ Two flips make old components theme correctly, per the handoff:
+ 1. The --ink-* ramp is INVERTED (ink-50 = darkest). Components use ink-100 as a hover
+ wash and ink-150/200 as hairlines; inverting keeps those roles intact.
+ 2. --rose-700 / --jade-700 become LIGHT tints — components use them as "text on a
+ soft brand tint" (avatar initials, plan pill, streak pill), so they have to flip
+ to stay legible.
+
+ --white is NOT re-pointed in THIS file. Under subturtle-theme.css it becomes the card
+ neutral, which is why nothing that means literal white may say var(--white) any more —
+ those now say `st-*-on-brand`, a hard #fff. Never use --white as a surface either way;
+ use --surface-card.
+ ============================================================ */
+
+html[data-theme='dark'] {
+ color-scheme: dark;
+
+ /* ---- Neutrals: inverted warm mauve ramp ---------------- */
+ --ink-50: 34 28 41; /* #221c29 */
+ --ink-100: 42 35 50; /* #2a2332 — row hover wash */
+ --ink-150: 50 42 59; /* #322a3b — divider hairline */
+ --ink-200: 58 49 68; /* #3a3144 — subtle border */
+ --ink-300: 75 65 87; /* #4b4157 */
+ --ink-400: 108 97 121; /* #6c6179 */
+ --ink-500: 157 147 169; /* #9d93a9 — muted text, 5.9:1 on --surface-card */
+ --ink-600: 184 175 194; /* #b8afc2 */
+ --ink-700: 207 200 215; /* #cfc8d7 */
+ --ink-800: 229 224 234; /* #e5e0ea — body text, 13.1:1 */
+ --ink-900: 241 237 244; /* #f1edf4 */
+ --ink-950: 250 248 251; /* #faf8fb — strong text */
+ --paper: 22 18 25; /* #161219 */
+
+ /* ---- Brand ------------------------------------------- */
+ --color-primary: 255 47 102; /* #ff2f66 — +2% lift so rose holds on dark */
+ --color-primary-hover: 255 69 119; /* #ff4577 */
+ --color-primary-press: 227 11 75; /* #e30b4b */
+ --color-primary-soft: 72 28 49; /* rgba(249,30,90,.18) over --surface-card */
+ --color-primary-tint: 55 27 45; /* rgba(249,30,90,.10) over --surface-card */
+ /* Explicit literals, NOT var(--white): subturtle-theme.css redeclares --white as the card
+ neutral, and an alias declared at :root has already resolved by the time we get here. */
+ --color-on-primary: 255 255 255;
+ --color-on-accent: 255 255 255;
+ --rose-700: 255 179 200; /* #ffb3c8 — now "text on rose soft" */
+ --rose-800: 255 143 174; /* #ff8fae */
+ --rose-200: 106 18 49; /* #6a1231 — ::selection background */
+
+ --color-accent: 34 199 149; /* #22c795 */
+ --color-accent-hover: 52 205 156; /* #34cd9c */
+ --color-accent-soft: 31 55 57; /* rgba(20,180,133,.18) over --surface-card */
+ --color-accent-tint: 32 42 49; /* rgba(20,180,133,.10) over --surface-card */
+ --jade-700: 110 226 185; /* #6ee2b9 — "text on jade soft" */
+
+ /* ---- Text -------------------------------------------- */
+ --text-strong: var(--ink-950);
+ --text-body: var(--ink-800);
+ --text-muted: var(--ink-500);
+ --text-faint: var(--ink-400);
+ --text-on-dark: 255 255 255;
+ --text-link: 255 143 174; /* #ff8fae */
+
+ /* ---- Surfaces ---------------------------------------- */
+ --surface-page: 22 18 25; /* #161219 */
+ --surface-card: 33 27 40; /* #211b28 */
+ --surface-sunken: 27 22 32; /* #1b1620 */
+ --surface-raised: 42 35 49; /* #2a2331 */
+ --surface-inverse: 250 248 251; /* #faf8fb */
+ /* Channels, so callers pick their own alpha. The handoff's rgba(8,5,10,.66) is that
+ alpha applied at the call site; scrims here ask for their own. */
+ --surface-overlay: 8 5 10;
+
+ /* ---- Borders & rings --------------------------------- */
+ --border-subtle: 58 49 68; /* #3a3144 */
+ --border-default: 75 65 87; /* #4b4157 */
+ --border-strong: 108 97 121; /* #6c6179 */
+ /* A finished color, not channels — it is dropped straight into box-shadow. */
+ --ring-focus: rgb(255 47 102 / 0.45);
+
+ /* ---- Status ------------------------------------------ */
+ --color-success: 34 199 149; /* #22c795 */
+ --color-success-soft: 31 55 57; /* rgba(20,180,133,.18) over --surface-card */
+ --color-warning: 247 185 85; /* #f7b955 */
+ --color-warning-soft: 71 52 39; /* rgba(245,165,36,.18) over --surface-card */
+ --color-danger: 255 107 107; /* #ff6b6b */
+ --color-danger-soft: 69 40 51; /* rgba(255,107,107,.16) over --surface-card */
+ --color-info: 76 178 245; /* #4cb2f5 */
+ --color-info-soft: 33 50 76; /* rgba(31,156,240,.18) over --surface-card */
+
+ /* ---- Shadows: the warm light tint reads as nothing on dark ---- */
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.4);
+ --shadow-sm: 0 1px 3px rgb(0 0 0 / 0.45), 0 1px 2px rgb(0 0 0 / 0.3);
+ --shadow-md: 0 4px 14px rgb(0 0 0 / 0.48), 0 2px 4px rgb(0 0 0 / 0.32);
+ --shadow-lg: 0 16px 40px rgb(0 0 0 / 0.55), 0 4px 12px rgb(0 0 0 / 0.38);
+ --shadow-xl: 0 28px 60px rgb(0 0 0 / 0.62), 0 10px 20px rgb(0 0 0 / 0.4);
+ --shadow-primary: 0 8px 22px rgb(249 30 90 / 0.38);
+ --shadow-accent: 0 8px 22px rgb(20 180 133 / 0.34);
+ --shadow-inset: inset 0 1px 2px rgb(0 0 0 / 0.45);
+
+ /* Repo-local additions (not in the handoff), both covering audit items whose only
+ per-theme difference is an alpha the markup cannot express as a token otherwise:
+ the ambient blobs "drop to ~3% in dark", and the topbar's backdrop-filter wants a
+ translucent --surface-card (the handoff's rgba(33,27,40,.72)). */
+ --blob-alpha: 0.03;
+ --surface-topbar: rgb(33 27 40 / 0.72);
+
+ /* ---- The -600 steps, same flip, same reason ------------
+ The handoff flips --rose-700 / --jade-700 because components use them as "text on a
+ soft brand tint". This codebase uses the -600 steps in exactly that role too, and the
+ handoff's audit did not enumerate them: StIconButton's soft variants
+ (st-text-rose-600 / st-text-jade-600), StBadge's soft variants (st-text-red-600,
+ st-text-amber-600, st-text-sky-600), the Progress lock tile and LoginBoardPreview.
+ Left unflipped they keep their light values and fail the handoff's own contrast bar —
+ measured #e30b4b on --color-primary-soft is 2.8:1, under the 3:1 it requires for icons.
+
+ Every value here is one the handoff already supplies for the adjacent step or the
+ status colour, reused rather than invented:
+ --rose-600 = the handoff's --rose-800 / --text-link (#ff8fae), 6.2:1 on rose soft
+ --jade-600 = the handoff's --color-accent-hover (#34cd9c), 5.9:1 on jade soft
+ --red-600 / --amber-600 / --sky-600 = the handoff's --color-danger / -warning / -info
+
+ NOTE for review: --red-600 on --color-danger-soft measures 4.4:1, marginally under AA
+ for the 11px badge label. Flagged rather than substituted — picking a different red
+ is a design call, not an implementation one. */
+ --rose-600: 255 143 174; /* #ff8fae */
+ --jade-600: 52 205 156; /* #34cd9c */
+ --red-600: 255 107 107; /* #ff6b6b */
+ --amber-600: 247 185 85; /* #f7b955 */
+ --sky-600: 76 178 245; /* #4cb2f5 */
+}
+
+/* Elevated dark surfaces get a 1px top highlight instead of a heavier shadow — keeps the
+ friendly, soft feel. */
+html[data-theme='dark'] .st-elevated,
+html[data-theme='dark'] [data-elevated] {
+ box-shadow: var(--shadow-lg), inset 0 1px 0 rgb(255 255 255 / 0.06);
+}
+
+/* Themed images/illustrations: dim by 8% so nothing glares. */
+html[data-theme='dark'] img:not([data-no-dim]) {
+ filter: brightness(0.94);
+}
+
+/* No transition on the theme flip itself — only on hover/press — otherwise the whole page
+ cross-fades and feels laggy. The consumer adds this class for one frame around a change
+ (in the dashboard, plugins/theme.client.ts). Deliberately un-namespaced: it has to reach
+ the host app's own markup, not just `st-` components. */
+html.theme-switching *,
+html.theme-switching *::before,
+html.theme-switching *::after {
+ transition: none !important;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/ui/src/styles/tokens.css b/ui/src/styles/tokens.css
index d0818c0b..d353482b 100644
--- a/ui/src/styles/tokens.css
+++ b/ui/src/styles/tokens.css
@@ -99,6 +99,13 @@
/* A finished color, not channels — it is dropped straight into box-shadow. */
--ring-focus: rgb(249 30 90 / 0.35);
+ /* Opacity of the ambient page blobs. A token because it is the one thing that differs
+ between themes there — dark drops to 3% so they don't glow (see theme-tokens.css). */
+ --blob-alpha: 0.05;
+ /* Finished colors: the topbar is translucent over its own scroll content, so the alpha
+ is part of the value and differs per theme. */
+ --surface-topbar: rgb(248 245 247 / 0.8);
+
--color-success: var(--jade-500);
--color-success-soft: var(--jade-100);
--color-warning: var(--amber-500);
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index d331aa2a..010f5035 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -14,6 +14,24 @@ export interface StNavGroup {
items: StNavItem[];
}
+/** One row in StProfileMenu. Mirrors ProfileMenuItem from the design system's .d.ts. */
+export interface StProfileMenuItem {
+ /** Row label — sentence case ("Sign out", not "Sign Out"). */
+ label: string;
+ /** Iconify Solar icon name, e.g. 'solar:settings-linear'. Must be in the generated set. */
+ icon: string;
+ /** Destructive row: rose-red text, red-tinted hover. */
+ danger?: boolean;
+ /** Draw a hairline divider above this row. */
+ dividerBefore?: boolean;
+ /** Optional trailing micro-label (counter, shortcut). Omit rather than showing a placeholder. */
+ meta?: string;
+ onClick?: () => void;
+}
+
+/** The three states of the appearance switch. `system` follows `prefers-color-scheme`. */
+export type StTheme = 'light' | 'dark' | 'system';
+
export type StTone = 'primary' | 'accent' | 'neutral' | 'danger';
export type StSize = 'sm' | 'md' | 'lg';
export type StPadding = 'none' | 'sm' | 'md' | 'lg';
diff --git a/ui/tailwind.config.cjs b/ui/tailwind.config.cjs
index ec14a96b..63c00a18 100644
--- a/ui/tailwind.config.cjs
+++ b/ui/tailwind.config.cjs
@@ -17,7 +17,7 @@ const scale = (name, steps) => Object.fromEntries(steps.map((s) => [String(s), c
module.exports = {
prefix: 'st-',
- darkMode: 'class',
+ darkMode: ['selector', '[data-theme="dark"]'],
content: ['./src/**/*.{vue,ts}'],
corePlugins: { preflight: false },
theme: {
@@ -26,6 +26,12 @@ module.exports = {
current: 'currentColor',
inherit: 'inherit',
white: c('--white'),
+ // Literal #fff, immune to theming. The design system's dark layer redeclares
+ // --white as the CARD NEUTRAL (#1e1826), so `white` is no longer a safe way to
+ // say "ink on a rose CTA" — that meaning lives here instead. Anything sitting on
+ // a brand or status fill uses `on-brand`; anything that is a SURFACE uses
+ // `bg-card`. Never `white` for either.
+ 'on-brand': '#fff',
paper: c('--paper'),
rose: scale('rose', [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]),
jade: scale('jade', [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]),
@@ -62,6 +68,9 @@ module.exports = {
faint: c('--text-faint'),
link: c('--text-link'),
'on-dark': c('--text-on-dark'),
+ // The readable pairing for `bg-inverse`: it is the page colour, so it flips
+ // with the surface instead of being a literal that only works in one theme.
+ page: c('--surface-page'),
},
backgroundColor: {
page: c('--surface-page'),
@@ -69,11 +78,18 @@ module.exports = {
sunken: c('--surface-sunken'),
raised: c('--surface-raised'),
inverse: c('--surface-inverse'),
+ // Scrims and image-overlay pills. NOT `ink-950` — that ramp inverts in dark,
+ // so a scrim written against it would turn into a white veil.
+ overlay: c('--surface-overlay'),
},
borderColor: {
DEFAULT: c('--border-subtle'),
subtle: c('--border-subtle'),
+ default: c('--border-default'),
strong: c('--border-strong'),
+ // For rings that punch a component out of whatever it sits on (the avatar's).
+ // `white` would be a literal, and literal white is only ever ink on a rose CTA.
+ card: c('--surface-card'),
},
fontFamily: {
sans: 'var(--font-sans)',