feat(theme): dark/light mode with a Radix Colors-based palette

Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
This commit is contained in:
2026-07-31 10:54:31 +07:00
parent 4905d63b97
commit b673363351
82 changed files with 3776 additions and 2578 deletions
+77 -12
View File
@@ -8,10 +8,74 @@
@tailwind components;
@tailwind utilities;
/* Palette sourced from Radix Colors (radix-ui.com/colors), an accessible
* 12-step system built for UI, contrast-checked with APCA — chosen after
* three rounds of hand-picked values that kept overshooting (too flat, then
* too dark, then too glaring). Radix's own role guidance for a 12-step scale:
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
* 3-5 component background 9-10 solid (buttons, vibrant fills)
* 11-12 text (11 secondary, 12 primary)
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
* RGB triplets for Tailwind's `<alpha-value>` opacity support.
*/
:root {
/* Light theme — the CSS default, so it applies with no class on <html>.
Radix `slate` (light), steps 1/2/3/4/5/6/7. */
--surface-0: 252 252 253; /* slate-1 #fcfcfd */
--surface-1: 249 249 251; /* slate-2 #f9f9fb */
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */
--surface-3: 232 232 236; /* slate-4 #e8e8ec */
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
--border: 217 217 224; /* slate-6 #d9d9e0 */
--border-light: 205 206 214; /* slate-7 #cdced6 */
--accent: 0 144 255; /* blue-9 #0090ff */
--accent-hover: 5 136 240; /* blue-10 #0588f0 */
--fg-primary: 28 32 36; /* slate-12 #1c2024 */
--fg-secondary: 96 100 108; /* slate-11 #60646c */
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
/* Status: green/red/amber step 11 (light) — the same "readable text" step
as fg-secondary, so success/danger/warning sit at the same visual weight
as ordinary secondary text, not shouting over it. */
--status-success: 33 131 88; /* green-11 #218358 */
--status-danger: 206 44 49; /* red-11 #ce2c31 */
--status-warning: 171 100 0; /* amber-11 #ab6400 */
}
.dark {
/* Radix `slate` (dark), steps 1/2/3/4/5/6/7. The scale is built in the
opposite direction on purpose — step 6 is LIGHTER than step 3/4 here,
which is what makes borders rise off a panel instead of sinking into it
(the bug in the previous, hand-picked dark border). */
--surface-0: 17 17 19; /* slate-1 #111113 */
--surface-1: 24 25 27; /* slate-2 #18191b */
--surface-2: 33 34 37; /* slate-3 #212225 */
--surface-3: 39 42 45; /* slate-4 #272a2d */
--surface-4: 46 49 53; /* slate-5 #2e3135 */
--surface-5: 54 58 63; /* slate-6 #363a3f */
--border: 54 58 63; /* slate-6 #363a3f */
--border-light: 67 72 78; /* slate-7 #43484e */
--accent: 0 144 255; /* blue-9 #0090ff — identical value both themes, Radix's brand anchor */
--accent-hover: 59 158 255; /* blue-10 #3b9eff */
--fg-primary: 237 238 240; /* slate-12 #edeef0 */
--fg-secondary: 176 180 186; /* slate-11 #b0b4ba */
--fg-muted: 105 110 119; /* slate-9 #696e77 */
/* Dark surfaces already separate by lightness step; a shadow adds nothing
visible against a dark page and reads as a stray dark smudge. */
--shadow-card: none;
--status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */
}
@layer base {
* {
scrollbar-width: thin;
scrollbar-color: #2a2a3d #0c0c14;
scrollbar-color: rgb(var(--border)) rgb(var(--surface-0));
}
*::-webkit-scrollbar {
@@ -20,21 +84,21 @@
}
*::-webkit-scrollbar-track {
background: #0c0c14;
background: rgb(var(--surface-0));
}
*::-webkit-scrollbar-thumb {
background: #2a2a3d;
background: rgb(var(--border));
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: #363650;
background: rgb(var(--border-light));
}
::selection {
background: rgba(99, 102, 241, 0.3);
color: #e4e4ed;
background: rgb(var(--accent) / 0.3);
color: rgb(var(--fg-primary));
}
::-webkit-calendar-picker-indicator {
@@ -57,6 +121,7 @@
@layer components {
.card {
@apply bg-surface-3 border border-border rounded-xl;
box-shadow: var(--shadow-card);
}
.card-hover {
@@ -68,7 +133,7 @@
}
.btn-ghost {
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200 hover:bg-surface-4 rounded-lg transition-colors duration-150;
@apply inline-flex items-center gap-2 px-3 py-1.5 text-sm text-fg-secondary hover:text-fg-primary hover:bg-surface-4 rounded-lg transition-colors duration-150;
}
.badge {
@@ -76,7 +141,7 @@
}
.input {
@apply bg-surface-2 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors;
@apply bg-surface-2 border border-border rounded-lg px-3 py-2 text-sm text-fg-primary placeholder-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors;
}
}
@@ -98,9 +163,9 @@
z-index: 100;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid #363650;
background: #15151f;
color: #e4e4ed;
border: 1px solid rgb(var(--border-light));
background: rgb(var(--surface-2));
color: rgb(var(--fg-primary));
font-size: 0.8125rem;
font-weight: 500;
text-decoration: none;
@@ -109,7 +174,7 @@
}
.skip-to-content:focus {
transform: translateY(0);
outline: 2px solid #6366f1;
outline: 2px solid rgb(var(--accent));
outline-offset: 2px;
}
}