" action */
.tip-action-move {
- color: #000;
+ color: var(--c-gray-900);
}
/* ---------- the ✗ shown when a guess was wrong ----------
diff --git a/frontend/src/components/collaborators/CollaboratorsPanel.css b/frontend/src/components/collaborators/CollaboratorsPanel.css
index 6916963..c0c9a2e 100644
--- a/frontend/src/components/collaborators/CollaboratorsPanel.css
+++ b/frontend/src/components/collaborators/CollaboratorsPanel.css
@@ -100,7 +100,7 @@
.collab-badge-edit {
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
.collab-badge-train {
@@ -135,15 +135,18 @@
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
- background: var(--c-slate-800);
- color: var(--c-surface);
+ /* the app's primary fill, same as the repertoire's add button and the
+ settings dialog's save — this is the affirmative action of its form */
+ background: var(--c-brand-blue);
+ color: var(--c-on-accent);
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
+ transition: background 150ms;
}
.collab-add-btn:hover {
- background: var(--c-slate-700);
+ background: var(--c-accent-strong);
}
/* ---------- result message ---------- */
diff --git a/frontend/src/components/common/ToggleGroup.css b/frontend/src/components/common/ToggleGroup.css
index 3b8fe28..4eb8988 100644
--- a/frontend/src/components/common/ToggleGroup.css
+++ b/frontend/src/components/common/ToggleGroup.css
@@ -33,5 +33,5 @@
.toggle-group-option.is-selected {
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
diff --git a/frontend/src/components/modals/AddToRepertoireModal.css b/frontend/src/components/modals/AddToRepertoireModal.css
index 1f9939e..16e68dd 100644
--- a/frontend/src/components/modals/AddToRepertoireModal.css
+++ b/frontend/src/components/modals/AddToRepertoireModal.css
@@ -175,6 +175,9 @@
border-radius: 0 0.5rem 0.5rem 0;
}
+/* The one fill in the app made of a neutral rather than an accent, so unlike
+ every other filled control this label does track --c-surface: the ramp step
+ under it flips with the theme, and the text has to flip back with it. */
.add-rep-color input:checked + .add-rep-color-label {
background: var(--c-gray-700);
color: var(--c-surface);
@@ -187,9 +190,9 @@
gap: 0.75rem;
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
- border: 1px solid var(--c-amber-300);
+ border: 1px solid var(--c-warn-line);
border-radius: 0.5rem;
- background: var(--c-amber-50);
+ background: var(--c-warn-bg);
}
.add-rep-signed-out svg {
@@ -197,24 +200,24 @@
width: 1.25rem;
height: 1.25rem;
margin-top: 0.125rem;
- color: var(--c-amber-500);
+ color: var(--c-warn-icon);
}
.add-rep-signed-out p {
font-size: 0.875rem;
line-height: 1.375;
- color: var(--c-amber-900);
+ color: var(--c-warn-fg);
}
.add-rep-signed-out button {
font-weight: 600;
- color: var(--c-amber-900);
+ color: var(--c-warn-fg);
text-decoration: underline;
text-underline-offset: 2px;
}
.add-rep-signed-out button:hover {
- color: var(--c-amber-950);
+ color: var(--c-warn-fg-strong);
}
/* ---------- submit ---------- */
@@ -223,7 +226,7 @@
padding: 0.5rem 1rem;
border-radius: 0.25rem;
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
font-size: 1.125rem;
font-weight: 700;
transition: filter 150ms, background-color 150ms;
@@ -255,11 +258,11 @@
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
- border: 1px solid var(--c-green-200);
+ border: 1px solid var(--c-success-line);
border-radius: 0.375rem;
- background: var(--c-green-50);
+ background: var(--c-success-bg);
font-size: 0.875rem;
- color: var(--c-green-800);
+ color: var(--c-success-fg);
}
.add-rep-json-loaded svg {
@@ -275,7 +278,9 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- color: rgb(21 128 61 / 0.8);
+ /* a shade quieter than the "loaded" label it sits beside */
+ color: var(--c-success-fg);
+ opacity: 0.8;
}
.add-rep-json-idle {
@@ -312,7 +317,7 @@
padding: 0.5rem 1rem;
border-radius: 0.25rem;
background: var(--c-green-600);
- color: var(--c-surface);
+ color: var(--c-on-accent);
font-size: 1.125rem;
font-weight: 700;
transition: background-color 150ms;
diff --git a/frontend/src/components/modals/DownloadModal.css b/frontend/src/components/modals/DownloadModal.css
index 1c2bdfa..9b8a852 100644
--- a/frontend/src/components/modals/DownloadModal.css
+++ b/frontend/src/components/modals/DownloadModal.css
@@ -23,8 +23,8 @@
.download-option.is-selected {
border-color: var(--c-brand-blue);
- background: var(--c-blue-50);
- color: var(--c-blue-900);
+ background: var(--c-info-bg);
+ color: var(--c-info-fg);
}
.download-option:disabled {
@@ -54,7 +54,7 @@
padding: 0.625rem 1rem;
border-radius: 0.5rem;
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
font-size: 0.875rem;
font-weight: 600;
transition: filter 150ms, transform 150ms;
diff --git a/frontend/src/components/modals/EditChapterModal.css b/frontend/src/components/modals/EditChapterModal.css
index 174153c..696221c 100644
--- a/frontend/src/components/modals/EditChapterModal.css
+++ b/frontend/src/components/modals/EditChapterModal.css
@@ -49,7 +49,7 @@
padding: 0.5rem;
border-radius: 0.375rem;
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
transition: all 150ms;
}
@@ -106,7 +106,7 @@
.edit-chapter-delete-btn {
background: var(--c-red-500);
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
.edit-chapter-delete-btn:hover {
@@ -133,7 +133,7 @@
.edit-chapter-confirm-yes {
background: var(--c-red-600);
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
.edit-chapter-confirm-yes:hover {
diff --git a/frontend/src/components/modals/LoginModal.css b/frontend/src/components/modals/LoginModal.css
index af90df2..e3f0d18 100644
--- a/frontend/src/components/modals/LoginModal.css
+++ b/frontend/src/components/modals/LoginModal.css
@@ -75,7 +75,7 @@
padding: 0.5rem 0;
border-radius: 0.25rem;
background: var(--c-emerald-600);
- color: var(--c-surface);
+ color: var(--c-on-accent);
font-size: 0.875rem;
font-weight: 600;
box-shadow: 0 0 0 2px var(--c-emerald-300);
diff --git a/frontend/src/components/modals/SettingsModal.css b/frontend/src/components/modals/SettingsModal.css
index e932ba9..ccb67f2 100644
--- a/frontend/src/components/modals/SettingsModal.css
+++ b/frontend/src/components/modals/SettingsModal.css
@@ -162,7 +162,7 @@
font-weight: 700;
font-family: inherit;
/* the app's primary fill, same as the repertoire's add button */
- color: var(--c-surface);
+ color: var(--c-on-accent);
background: var(--c-brand-blue);
border: none;
border-radius: 0.5rem;
@@ -171,5 +171,5 @@
}
.settings-done-btn:hover {
- background: var(--c-blue-800);
+ background: var(--c-accent-strong);
}
diff --git a/frontend/src/components/pgn/ContextMenuProvider.css b/frontend/src/components/pgn/ContextMenuProvider.css
index 61478f6..5da4147 100644
--- a/frontend/src/components/pgn/ContextMenuProvider.css
+++ b/frontend/src/components/pgn/ContextMenuProvider.css
@@ -9,14 +9,14 @@
max-width: 92vw;
border: 1px solid var(--c-gray-200);
border-radius: 0.75rem;
- background: rgb(255 255 255 / 0.95);
+ background: var(--c-glass-strong);
color: var(--c-gray-900);
box-shadow: var(--shadow-xl);
}
@supports (backdrop-filter: blur(0)) {
.cr-ctxmenu {
- background: rgb(255 255 255 / 0.8);
+ background: var(--c-glass-hover);
backdrop-filter: blur(8px);
}
}
diff --git a/frontend/src/components/pgn/Tree.css b/frontend/src/components/pgn/Tree.css
index 54ae699..5200299 100644
--- a/frontend/src/components/pgn/Tree.css
+++ b/frontend/src/components/pgn/Tree.css
@@ -65,7 +65,7 @@
.move.active:hover {
background: var(--c-brand-blue);
border-radius: 0.375rem;
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
/* the inline move number rides along with the selected move's text color */
@@ -235,7 +235,7 @@
font-size: 0.75rem;
font-weight: 600;
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
transition: filter 150ms;
}
@@ -285,7 +285,7 @@
}
.tree-child-move:hover {
- background: var(--c-blue-50);
+ background: var(--c-info-bg);
border-color: var(--c-brand-blue);
- color: var(--c-brand-blue);
+ color: var(--c-info-accent);
}
diff --git a/frontend/src/components/repertoire/Repertoire.css b/frontend/src/components/repertoire/Repertoire.css
index e676339..597db13 100644
--- a/frontend/src/components/repertoire/Repertoire.css
+++ b/frontend/src/components/repertoire/Repertoire.css
@@ -39,13 +39,13 @@
.panel-action.repertoire-add-btn {
padding: 0.375rem 0.625rem;
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
font-weight: 700;
}
.panel-action.repertoire-add-btn:hover:not(:disabled) {
- background: var(--c-blue-800);
- color: var(--c-surface);
+ background: var(--c-accent-strong);
+ color: var(--c-on-accent);
}
/* Nothing imported yet — ring the add button so the empty state has an
@@ -222,12 +222,12 @@
progress bar above the board uses, at full strength rather than tinted. */
.chapter-count-unseen {
background: var(--c-brand-blue-light);
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
.chapter-count-due {
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
.chapter-btn-label {
@@ -306,7 +306,7 @@
background: var(--c-brand-blue);
font-size: 0.8125rem;
font-weight: 400;
- color: var(--c-surface);
+ color: var(--c-on-accent);
transition:
background 150ms,
filter 150ms;
@@ -364,7 +364,7 @@
justify-content: center;
border-radius: 0.375rem;
background: var(--c-brand-blue);
- color: var(--c-surface);
+ color: var(--c-on-accent);
transition: all 150ms;
}
@@ -414,7 +414,7 @@
.chapter-action-confirm-yes {
background: var(--c-red-600);
- color: var(--c-surface);
+ color: var(--c-on-accent);
}
.chapter-action-confirm-yes:hover {
diff --git a/frontend/src/components/repertoire/Repertoire.tsx b/frontend/src/components/repertoire/Repertoire.tsx
index 4724db7..bbc5ecb 100644
--- a/frontend/src/components/repertoire/Repertoire.tsx
+++ b/frontend/src/components/repertoire/Repertoire.tsx
@@ -25,10 +25,23 @@ import DownloadModal from '../modals/DownloadModal';
import React, { useEffect, useState } from 'react';
import { Chapter } from '../../types/training';
import { useAuthStore } from '../../store/auth';
+import { useThemeStore } from '../../store/theme';
import { viewUserRepertoire } from '../../services/collaborators';
import './Repertoire.css';
+// Which king glyph stands for a side, given the color the glyph is drawn in.
+//
+// The convention is about ink, not about the label: the *filled* king is
+// whichever side matches the ink, because a solid shape reads as that color
+// and a hollow one reads as the background showing through. On a light page
+// the ink is dark, so filled = black and outline = white. Invert the page and
+// the ink goes light, so the same two glyphs now say the opposite thing —
+// hence the swap rather than a second icon set.
+const kingFor = (side: 'white' | 'black', isDark: boolean) =>
+ (side === 'black') !== isDark ? FaChessKing : FaRegChessKing;
+
export const ChapterRow = ({ entry, index }) => {
+ const isDark = useThemeStore((s) => s.theme === 'dark');
const setSelectedChapterId = useStore(useTrainerStore, (s) => s.setSelectedChapterId);
const clearChapterContext = useTrainerStore((s) => s.clearChapterContext);
const renameChapter = useTrainerStore((s) => s.renameChapter);
@@ -46,6 +59,7 @@ export const ChapterRow = ({ entry, index }) => {
const name = entry.name;
const isSelected = selectedChapterId === entry.uuid;
const isOpen = view !== 'closed';
+ const SideKing = kingFor(entry.trainAs, isDark);
const actionsId = `chapter-actions-${entry.uuid}`;
//TODO dont change if already on this chapter..
@@ -117,10 +131,10 @@ export const ChapterRow = ({ entry, index }) => {
{index + 1}
- {/* Outline king for white, filled for black — the same way a chess
- diagram distinguishes the two sides. */}
+ {/* The same way a chess diagram distinguishes the two sides — which
+ glyph means which flips with the theme, see kingFor. */}
- {entry.trainAs === 'white' ? : }
+
diff --git a/frontend/src/css/base.css b/frontend/src/css/base.css
index cfc4863..284849b 100644
--- a/frontend/src/css/base.css
+++ b/frontend/src/css/base.css
@@ -8,9 +8,20 @@
Must be imported first (see main.tsx) — everything else expects the reset
to already be in place and overrides it by source order.
+
+ Themes: the block below is light, and the `[data-theme='dark']` block at the
+ bottom of this file redefines the same names for dark. No other stylesheet
+ knows a theme exists — they all read tokens, so a theme is only ever a new
+ set of values here. Anything you add elsewhere must go through a token, or
+ it will be a light-mode color stranded on a dark page.
=========================== */
:root {
+ /* Tells the browser which way the page reads, so the controls it paints
+ itself — inputs, textareas, selects, the range slider, the Firefox
+ scrollbar — follow the theme instead of staying white. */
+ color-scheme: light;
+
/* fonts (previously tailwind.config.js -> theme.extend.fontFamily) */
--font-sans: 'Fira Sans', 'Roboto', sans-serif;
--font-roboto: 'Roboto', sans-serif;
@@ -81,6 +92,69 @@
--c-orange-400: #fb923c;
--c-yellow-400: #facc15;
+ /* ---------- roles ----------
+ A ramp step can only hold one value per theme, so any step the app uses
+ for two jobs that move in opposite directions between light and dark has
+ to be split in two here first. Each name below is one job. */
+
+ /* Text and icons sitting on a saturated fill (the brand blue, the red of a
+ destructive confirm, the green of a submit). Those fills stay put across
+ themes, so this can't be --c-surface — that one goes dark and would take
+ the label with it. */
+ --c-on-accent: var(--c-surface);
+
+ /* The heavier step of the brand blue, for hovering a button already filled
+ with it. "Heavier" means darker on a light page and lighter on a dark one,
+ which is why it isn't just --c-blue-800. */
+ --c-accent-strong: var(--c-blue-800);
+
+ /* Callout tints — a pale wash of a hue, its outline, and the text that sits
+ on it. Grouped because the wash and its text have to move together: on a
+ dark page the wash goes dark and the text has to go light, while the same
+ ramp step used as text on a light-blue badge must stay dark. */
+ --c-info-bg: var(--c-blue-50);
+ --c-info-line: var(--c-blue-200);
+ --c-info-fg: var(--c-blue-900);
+ /* the lighter, brand-colored text used on the wash where it reads as an
+ accent rather than as body copy (a move comment, a hovered sideline) */
+ --c-info-accent: var(--c-brand-blue);
+
+ --c-warn-bg: var(--c-amber-50);
+ --c-warn-line: var(--c-amber-300);
+ --c-warn-icon: var(--c-amber-500);
+ --c-warn-fg: var(--c-amber-900);
+ --c-warn-fg-strong: var(--c-amber-950);
+
+ --c-success-bg: var(--c-green-50);
+ --c-success-line: var(--c-green-200);
+ --c-success-fg: var(--c-green-800);
+
+ /* Translucent fills. The first two are cards that let what's behind them
+ show through (the promotion chooser over the board, the move context
+ menu); the last two are the faint inset wells inside them. All four
+ invert — a white veil and a black well become a black veil and a white
+ well — so they can't be written as literal rgb() at the use site. */
+ --c-glass: rgb(255 255 255 / 0.9);
+ --c-glass-strong: rgb(255 255 255 / 0.95);
+ --c-glass-hover: rgb(255 255 255 / 0.8);
+ --c-well: rgb(0 0 0 / 0.05);
+ --c-well-hover: rgb(0 0 0 / 0.1);
+
+ /* the second half of the wordmark, and the thumb of every scrollbar */
+ --c-wordmark-accent: #57534e;
+ --c-scrollbar: var(--c-gray-400);
+ --c-scrollbar-hover: var(--c-gray-500);
+
+ /* Due-count heatmap, empty day -> busiest day in the window. Its own scale
+ rather than the blue ramp above: it has to stay legible as five distinct
+ steps, which the ramp's spacing doesn't give. */
+ --c-heat-0: #dfdfdf;
+ --c-heat-1: #c9deff;
+ --c-heat-2: #91bbff;
+ --c-heat-3: #6991ff;
+ --c-heat-4: #1856ff;
+ --c-heat-hover: #555;
+
/* surface chrome: --c-recessed is the app's secondary gray (the segmented
track under the board, the selected chapter row), --c-hairline the faint
outline of a nested box, --c-line the outline of a raised card or chip. */
@@ -102,6 +176,145 @@
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
}
+/* ===========================
+ Dark theme
+
+ Same names, other values. `data-theme` is written on — by the inline
+ script in index.html before first paint, and by the theme store after that
+ (src/store/theme.ts) — so there is no light frame to flash through.
+
+ The neutral ramps are *reversed* rather than replaced: 50 becomes the
+ darkest step and 900 the lightest. That is what makes the rest of the app
+ come along for free — every `color: var(--c-gray-800)` is text and turns
+ light, every `background: var(--c-gray-100)` is a fill and turns dark, with
+ no per-rule dark variants anywhere. It only holds because the app uses the
+ ramp by role (pale = fill, deep = ink); the few places that broke the rule
+ were split into named roles above before this block was written.
+
+ The accents are *not* reversed — a fill that stays put across themes (the
+ brand blue, the reds and greens of a confirm) keeps its value, and only the
+ washes and the text on them flip.
+ =========================== */
+:root[data-theme='dark'] {
+ color-scheme: dark;
+
+ /* Page chrome keeps the light theme's ordering — recessed sits below the
+ page, surface above it — so a sunken track still reads as sunken and a
+ card still reads as raised, just measured downward from a dark page. */
+ --c-page-bg: #14171c;
+ --c-surface: #1c2027;
+ --c-recessed: #0f1216;
+ /* deeper than the light scrim: a 50% black over an already-dark page barely
+ separates the dialog from what's behind it */
+ --c-backdrop: rgb(0 0 0 / 0.7);
+
+ /* Anything absent from this block inherits from :root unchanged, and that is
+ deliberate for two kinds of token: the ones that already hold up on a dark
+ page (the brand blues, the emerald fills, the yellow attention ring), and
+ every role defined as `var(--c--)` up there — a role like that
+ re-resolves against the steps below on its own, so restating it here would
+ only be a second copy to keep in sync. */
+
+ /* neutrals, reversed. Slightly blued rather than a literal flip of the light
+ ramp: neutral grays go muddy against the blue-black page. */
+ --c-gray-50: #171a20;
+ --c-gray-100: #1f242c;
+ --c-gray-200: #2b313b;
+ --c-gray-300: #3b434f;
+ /* the ramp's midpoint is the one step that reads the same either way */
+ --c-gray-400: #6b7280;
+ --c-gray-500: #9199a6;
+ --c-gray-600: #b4bcc8;
+ --c-gray-700: #d3d9e1;
+ --c-gray-800: #e6eaef;
+ --c-gray-900: #f4f6f9;
+
+ --c-slate-200: #2b313b;
+ --c-slate-300: #3b434f;
+ --c-slate-500: #94a3b8;
+ --c-slate-600: #b6c0cd;
+ --c-slate-700: #d6dde6;
+ --c-slate-800: #e8edf3;
+ --c-slate-900: #f4f7fa;
+
+ /* Accents. The pale steps (50, 200) become dark washes and the deep steps
+ (800, 900) become light ink — except --c-blue-900, which is text on the
+ light-blue peer badge and so has to stay dark. */
+ --c-blue-50: #16233a;
+ --c-blue-200: #3b5f96;
+ --c-blue-800: #60a5fa;
+ --c-blue-900: #1e3a8a;
+ --c-sky-700: #60c5f5;
+ --c-cyan-50: #0d2a2f;
+
+ --c-green-50: #0f2418;
+ --c-green-200: #1f4d33;
+ --c-green-600: #22c55e;
+ --c-green-700: #16a34a;
+ --c-green-800: #86efac;
+ --c-emerald-300: #34d399;
+
+ --c-red-500: #f87171;
+ --c-red-600: #ef4444;
+ --c-red-700: #dc2626;
+
+ --c-amber-50: #2a1f08;
+ --c-amber-300: #6b4e12;
+ --c-amber-500: #fbbf24;
+ --c-amber-600: #fbbf24;
+ --c-amber-900: #fcd9a0;
+ --c-amber-950: #fef3c7;
+
+ --c-orange-400: #c2610c;
+
+ /* ---------- roles ---------- */
+
+ /* Near-white rather than the surface, which is now dark: this is the label
+ on a blue/red/green fill, and those fills did not move. */
+ --c-on-accent: #f8fafc;
+ /* "heavier" is lighter here — a darker blue on a dark page reads as
+ disabled, not as hovered */
+ --c-accent-strong: #60a5fa;
+
+ /* Only the two info roles the reversed ramp doesn't already land on: the
+ wash's outline wants more separation from the wash than blue-200 gives,
+ and the accent has to lift off a dark wash instead of a pale one. The warn
+ and success sets need nothing — their ramp steps flipped above. */
+ --c-info-line: #2b4a7a;
+ --c-info-fg: #bfdbfe;
+ --c-info-accent: #93c5fd;
+
+ /* the veils invert with the page: a white card over the board becomes a
+ dark one, and the wells inside it are lit rather than shaded */
+ --c-glass: rgb(28 32 39 / 0.92);
+ --c-glass-strong: rgb(28 32 39 / 0.96);
+ --c-glass-hover: rgb(28 32 39 / 0.85);
+ --c-well: rgb(255 255 255 / 0.06);
+ --c-well-hover: rgb(255 255 255 / 0.12);
+
+ --c-wordmark-accent: #a8a29e;
+ --c-scrollbar: #454c58;
+ --c-scrollbar-hover: #5d6673;
+
+ /* The heatmap's empty day has to sit *under* the card it's drawn on, the way
+ #dfdfdf does on white; the four filled steps climb to the same blue. */
+ --c-heat-0: #262c35;
+ --c-heat-1: #24406e;
+ --c-heat-2: #2f5ca8;
+ --c-heat-3: #3f7ce0;
+ --c-heat-4: #6ea8ff;
+ --c-heat-hover: #cbd5e1;
+
+ /* A shadow tuned for a white page is invisible on a dark one; these are the
+ same geometry at an alpha that still reads. */
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4);
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.5), 0 1px 2px -1px rgb(0 0 0 / 0.5);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.5);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.55), 0 4px 6px -4px rgb(0 0 0 / 0.55);
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.6), 0 8px 10px -6px rgb(0 0 0 / 0.6);
+ --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.8);
+}
+
/* ---------- reset ---------- */
/* Zero-width solid border on everything: rules below only ever have to set a
diff --git a/frontend/src/css/chessrepeat.css b/frontend/src/css/chessrepeat.css
index 7efdcdc..53b8c4f 100644
--- a/frontend/src/css/chessrepeat.css
+++ b/frontend/src/css/chessrepeat.css
@@ -104,7 +104,7 @@
}
.board-progress-tooltip .cell-icon-due svg {
- color: var(--c-blue-800);
+ color: var(--c-accent-strong);
}
.board-progress-tooltip .cell-label {
diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css
index 3b0a8e3..ca6ca4e 100644
--- a/frontend/src/css/layout.css
+++ b/frontend/src/css/layout.css
@@ -190,7 +190,7 @@
}
#header > .logo-wrap .accent {
- color: #57534e;
+ color: var(--c-wordmark-accent);
}
/* Lighter than the wordmark so the links read as secondary chrome. */
@@ -444,6 +444,8 @@
padding: 0.75rem;
}
+/* The color pair here is shared with .tip-icon (UserTip.css) — change both
+ together, or the tip's glyph stops matching the panel headers above it. */
.panel-icon {
flex-shrink: 0;
display: flex;
diff --git a/frontend/src/css/style.css b/frontend/src/css/style.css
index 7dd0752..4acc9a4 100644
--- a/frontend/src/css/style.css
+++ b/frontend/src/css/style.css
@@ -242,7 +242,7 @@ cg-board piece.fading {
}
.line > .branch {
- border-inline-start: 2px solid #e5e7eb;
+ border-inline-start: 2px solid var(--c-gray-200);
margin-inline-start: -9px;
position: absolute;
width: 8px;
@@ -252,7 +252,7 @@ cg-board piece.fading {
.line > .branch::before {
margin-top: 0.8em;
content: ' ';
- border-top: 2px solid #e5e7eb;
+ border-top: 2px solid var(--c-gray-200);
position: absolute;
width: 7px;
height: 6px;
@@ -334,7 +334,7 @@ cg-board piece.fading {
@supports not selector(::-webkit-scrollbar) {
* {
scrollbar-width: thin;
- scrollbar-color: #9ca3af transparent; /* gray-400 */
+ scrollbar-color: var(--c-scrollbar) transparent;
}
}
@@ -348,7 +348,7 @@ cg-board piece.fading {
}
*::-webkit-scrollbar-thumb {
- background: #9ca3af; /* gray-400 */
+ background: var(--c-scrollbar);
/* The transparent border insets the thumb inside the 9px gutter. Since the
background is clipped to the content box, the visible corner radius is
this radius minus the border width — 5px here reads as ~3px. */
@@ -358,7 +358,7 @@ cg-board piece.fading {
}
*::-webkit-scrollbar-thumb:hover {
- background: #6b7280; /* gray-500 */
+ background: var(--c-scrollbar-hover);
background-clip: content-box;
}
diff --git a/frontend/src/store/theme.ts b/frontend/src/store/theme.ts
new file mode 100644
index 0000000..ec9914a
--- /dev/null
+++ b/frontend/src/store/theme.ts
@@ -0,0 +1,47 @@
+import { create } from 'zustand';
+
+export type Theme = 'light' | 'dark';
+
+// Kept in sync with the inline bootstrap in index.html, which reads the same
+// key and stamps the same attribute before React ever mounts. Both halves have
+// to agree or the page paints one theme and then swaps to the other.
+export const THEME_KEY = 'chessrepeat:theme';
+
+function systemTheme(): Theme {
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+// The stored choice, or the OS preference when there isn't one. Reading from
+// rather than from storage: the bootstrap script already resolved this
+// exact question before first paint, so this can't disagree with what's on
+// screen even if storage is unreadable (Safari private mode throws).
+function initialTheme(): Theme {
+ const stamped = document.documentElement.dataset.theme;
+ return stamped === 'dark' || stamped === 'light' ? stamped : systemTheme();
+}
+
+function apply(theme: Theme) {
+ document.documentElement.dataset.theme = theme;
+ try {
+ localStorage.setItem(THEME_KEY, theme);
+ } catch {
+ // storage blocked — the theme still applies, it just won't outlive the tab
+ }
+}
+
+type ThemeState = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ toggleTheme: () => void;
+};
+
+export const useThemeStore = create((set, get) => ({
+ theme: initialTheme(),
+
+ setTheme: (theme) => {
+ apply(theme);
+ set({ theme });
+ },
+
+ toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
+}));