From bc487087087bbbce977dc8024d80b13f0463ee3d Mon Sep 17 00:00:00 2001 From: Kyle Jacobson Date: Mon, 10 Aug 2026 17:47:06 -0500 Subject: [PATCH 1/2] wip --- frontend/index.html | 18 ++ frontend/src/components/CommentBox.css | 6 +- frontend/src/components/GoogleLoginButton.tsx | 9 +- frontend/src/components/Header.tsx | 23 +- frontend/src/components/InsightChart.css | 12 +- .../src/components/MobileCommentPopout.css | 2 +- frontend/src/components/PromotionOverlay.css | 6 +- frontend/src/components/TrainingControls.css | 2 +- frontend/src/components/UserTip.css | 12 +- .../collaborators/CollaboratorsPanel.css | 4 +- .../src/components/common/ToggleGroup.css | 2 +- .../modals/AddToRepertoireModal.css | 29 ++- .../src/components/modals/DownloadModal.css | 6 +- .../components/modals/EditChapterModal.css | 6 +- frontend/src/components/modals/LoginModal.css | 2 +- .../src/components/modals/SettingsModal.css | 4 +- .../components/pgn/ContextMenuProvider.css | 4 +- frontend/src/components/pgn/Tree.css | 8 +- .../src/components/repertoire/Repertoire.css | 16 +- frontend/src/css/base.css | 213 ++++++++++++++++++ frontend/src/css/chessrepeat.css | 2 +- frontend/src/css/layout.css | 4 +- frontend/src/css/style.css | 10 +- frontend/src/store/theme.ts | 47 ++++ 24 files changed, 383 insertions(+), 64 deletions(-) create mode 100644 frontend/src/store/theme.ts diff --git a/frontend/index.html b/frontend/index.html index 5138e70..a99bd20 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,24 @@ Opening Training | chessrepeat.com + + + diff --git a/frontend/src/components/CommentBox.css b/frontend/src/components/CommentBox.css index e26ec50..89daf62 100644 --- a/frontend/src/components/CommentBox.css +++ b/frontend/src/components/CommentBox.css @@ -1,8 +1,8 @@ .comment-box { padding: 0.5rem 0.75rem; border-radius: 0.375rem; - background: var(--c-blue-50); - box-shadow: inset 0 0 0 1px var(--c-blue-200); + background: var(--c-info-bg); + box-shadow: inset 0 0 0 1px var(--c-info-line); font-size: 0.875rem; - color: var(--c-brand-blue); + color: var(--c-info-accent); } diff --git a/frontend/src/components/GoogleLoginButton.tsx b/frontend/src/components/GoogleLoginButton.tsx index 183502e..6dfdbb5 100644 --- a/frontend/src/components/GoogleLoginButton.tsx +++ b/frontend/src/components/GoogleLoginButton.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { useAuthStore } from '../store/auth'; +import { useThemeStore } from '../store/theme'; import { useTrainerStore } from '../store/state'; import { parseChapters } from '../util/chapters'; @@ -31,6 +32,7 @@ export function applyLoginResponse(data: any) { export function GoogleLoginButton({ onNeedsUsername, onSuccess }: Props) { const [ready, setReady] = useState(false); + const theme = useThemeStore((s) => s.theme); const updateDueCounts = useTrainerStore().updateDueCounts; useEffect(() => { @@ -74,14 +76,17 @@ export function GoogleLoginButton({ onNeedsUsername, onSuccess }: Props) { setReady(true); }, []); + // Google renders this button itself, so it can't inherit the palette — it + // only takes one of its own named themes. Re-rendered on a theme change + // because renderButton bakes the look in at call time. useEffect(() => { if (!ready) return; // @ts-ignore window.google.accounts.id.renderButton(document.getElementById('gbtn'), { - theme: 'outline', + theme: theme === 'dark' ? 'filled_black' : 'outline', size: 'large', }); - }, [ready]); + }, [ready, theme]); return
; } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 69a2230..31d7743 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,7 @@ import { SiDiscord, SiGithub } from 'react-icons/si'; -import { LogIn, LogOut, User } from 'lucide-react'; +import { LogIn, LogOut, Moon, Sun, User } from 'lucide-react'; import { useAuthStore } from '../store/auth'; +import { useThemeStore } from '../store/theme'; import { useTrainerStore, type Peer } from '../store/state'; interface Props { @@ -11,6 +12,10 @@ interface Props { } export function Header({ connectedUsers, incomingCollaboratorsCount = 0 }: Props) { + const theme = useThemeStore((s) => s.theme); + const toggleTheme = useThemeStore((s) => s.toggleTheme); + const isDark = theme === 'dark'; + const authUser = useAuthStore((s) => s.user); const clearAuth = useAuthStore((s) => s.clearAuth); const openLogin = useAuthStore((s) => s.openLogin); @@ -103,6 +108,22 @@ export function Header({ connectedUsers, incomingCollaboratorsCount = 0 }: Props
+ {/* Labelled by what it switches *to*, the way "hide comments" is, so + the icon and the words say the same thing. First in the cluster, so + the dot separator falls between it and the links rather than + before it. */} + + busiest day in the window */ .cr-heatmap .react-calendar-heatmap rect.color-cr-0 { - fill: #dfdfdf; + fill: var(--c-heat-0); } .cr-heatmap .react-calendar-heatmap rect.color-cr-1 { - fill: #c9deff; + fill: var(--c-heat-1); } .cr-heatmap .react-calendar-heatmap rect.color-cr-2 { - fill: #91bbff; + fill: var(--c-heat-2); } .cr-heatmap .react-calendar-heatmap rect.color-cr-3 { - fill: #6991ff; + fill: var(--c-heat-3); } .cr-heatmap .react-calendar-heatmap rect.color-cr-4 { - fill: #1856ff; + fill: var(--c-heat-4); } .cr-heatmap .react-calendar-heatmap rect.cr-today { @@ -76,7 +76,7 @@ } .cr-heatmap .react-calendar-heatmap rect:hover { - stroke: #555; + stroke: var(--c-heat-hover); stroke-width: 1; cursor: pointer; } diff --git a/frontend/src/components/MobileCommentPopout.css b/frontend/src/components/MobileCommentPopout.css index 188f852..3e7bc6d 100644 --- a/frontend/src/components/MobileCommentPopout.css +++ b/frontend/src/components/MobileCommentPopout.css @@ -96,7 +96,7 @@ padding: 0.25rem 0.75rem; border-radius: 0.25rem; background: var(--c-brand-blue); - color: var(--c-surface); + color: var(--c-on-accent); font-size: 0.75rem; font-weight: 600; transition: filter 150ms; diff --git a/frontend/src/components/PromotionOverlay.css b/frontend/src/components/PromotionOverlay.css index 69e0b64..74a360c 100644 --- a/frontend/src/components/PromotionOverlay.css +++ b/frontend/src/components/PromotionOverlay.css @@ -23,7 +23,7 @@ .promo-card { padding: 0.75rem; border-radius: 0.75rem; - background: rgb(255 255 255 / 0.9); + background: var(--c-glass); box-shadow: var(--shadow-xl); } @@ -38,12 +38,12 @@ width: 56px; height: 56px; border-radius: 0.5rem; - background: rgb(0 0 0 / 0.05); + background: var(--c-well); transition: background-color 150ms; } .promo-option:hover:not(:disabled) { - background: rgb(0 0 0 / 0.1); + background: var(--c-well-hover); } /* While learning, only the piece the line actually promotes to is selectable. */ diff --git a/frontend/src/components/TrainingControls.css b/frontend/src/components/TrainingControls.css index b72f65a..59dd410 100644 --- a/frontend/src/components/TrainingControls.css +++ b/frontend/src/components/TrainingControls.css @@ -41,6 +41,6 @@ --ring-pulse-out: rgb(59 130 246 / 0); /* fade out in blue, not toward black */ background: var(--c-brand-blue); border-color: var(--c-brand-blue); - color: var(--c-surface); + color: var(--c-on-accent); animation: ring-pulse 1.5s ease-out infinite; } diff --git a/frontend/src/components/UserTip.css b/frontend/src/components/UserTip.css index a636cc9..0dea41a 100644 --- a/frontend/src/components/UserTip.css +++ b/frontend/src/components/UserTip.css @@ -29,14 +29,20 @@ height: 2rem; } +/* Same two tokens as .panel-icon in layout.css — the boxed glyph in a panel + header (the calendar on the schedule, the book on the repertoire). This tip + sits directly under those panels, so a glyph a step lighter read as a + different kind of object. Keep the pair in sync with that rule; matching by + token rather than by value is what holds it together in both themes. + Geometry stays local: this one is larger and grows again at 768px. */ .tip-icon { padding: 0.375rem; border-radius: 0.375rem; - color: var(--c-gray-500); + color: var(--c-gray-600); } .tip-icon.is-boxed { - background: var(--c-gray-100); + background: var(--c-gray-200); } .tip-icon svg { @@ -101,7 +107,7 @@ /* the guessed move quoted inside the "ADD " 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..aa9d18e 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,6 +135,8 @@ gap: 0.375rem; padding: 0.5rem 1rem; border-radius: 0.25rem; + /* neutral fill, not an accent one — the slate step inverts with the theme, + so the label tracks the surface back the other way (see --c-on-accent) */ background: var(--c-slate-800); color: var(--c-surface); font-size: 0.875rem; 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/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'), +})); From e775bbb9c6a2fa1258a63a216640f90233163947 Mon Sep 17 00:00:00 2001 From: Kyle Jacobson Date: Mon, 10 Aug 2026 18:19:06 -0500 Subject: [PATCH 2/2] ui tweaks --- .../collaborators/CollaboratorsPanel.css | 11 +++++----- .../src/components/repertoire/Repertoire.tsx | 20 ++++++++++++++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/collaborators/CollaboratorsPanel.css b/frontend/src/components/collaborators/CollaboratorsPanel.css index aa9d18e..c0c9a2e 100644 --- a/frontend/src/components/collaborators/CollaboratorsPanel.css +++ b/frontend/src/components/collaborators/CollaboratorsPanel.css @@ -135,17 +135,18 @@ gap: 0.375rem; padding: 0.5rem 1rem; border-radius: 0.25rem; - /* neutral fill, not an accent one — the slate step inverts with the theme, - so the label tracks the surface back the other way (see --c-on-accent) */ - 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/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' ? : } +