feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and a named theme remembered per mode — dark: Default, Darcula, Apple Dark; light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a CSS-variable override block keyed by `data-theme` on <html>; the sidebar quick toggle gains System and swaps between the two remembered themes. - An inline boot script in index.html applies the stored mode and theme before first paint, so a reload no longer flashes the default colours. - Diff +/- rows are easier to tell apart on every theme: a /20 status tint, a 3px status bar and a bold coloured marker; the changed-span emphasis no longer vanishes on light themes. - #42: the Add-lane modal (Repo mode / "Current branch") asks POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside an existing lane resolved to that parent lane with created:false, so the modal closed and no new card appeared. When the folder really already has a lane, the modal now says which one instead of closing. Closes #42
This commit is contained in:
+4
-1
@@ -145,7 +145,10 @@ path. Existing cwd values still pass through unchanged. `pipeline` is
|
||||
optional and, like `title`, applies **only when a lane is actually created**
|
||||
— an existing lane is returned untouched. Omitted, the lane gets `default`.
|
||||
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
|
||||
field. An id no template defines returns `400 EBADPIPELINE`.
|
||||
field. `exact: true` makes only a lane on this very directory count: without
|
||||
it a `cwd` nested inside another lane returns that parent lane (`created:
|
||||
false`); with it the nested directory gets a lane of its own. The Add-lane
|
||||
modal sends it, and says so when the directory already has a lane. An id no template defines returns `400 EBADPIPELINE`.
|
||||
|
||||
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this lane's
|
||||
runs will spawn. Immutable once the lane is created; an attempt to update it on
|
||||
|
||||
+1
-1
@@ -472,7 +472,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
|
||||
```
|
||||
|
||||
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
|
||||
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it.
|
||||
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
|
||||
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
|
||||
- A relative or missing `cwd` returns `400 EBADCWD`.
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Appearance: mode + named themes, and readable diff rows
|
||||
|
||||
## Requirement (from the user, 2026-09-23)
|
||||
- `/settings` gets an **Appearance** section: mode **Dark / Light / System**, plus a named theme per mode.
|
||||
- Themes: dark = **Default** (current Radix slate), **Darcula**, **Apple Dark**; light = **Default** (current ivory), **Apple Light**, **IntelliJ Light**, **Canvas** (linen).
|
||||
- A theme also recolours code syntax (`--syn-*`).
|
||||
- The sidebar's dark/light control stays; each mode remembers its own theme (dark=Darcula, light=Canvas → toggling swaps between those two).
|
||||
- The diff's +/- rows in Code changes (`LaneDiffView`) get visibly more contrast, in every theme.
|
||||
|
||||
## Approach
|
||||
Themes are pure CSS-variable overrides — every colour in the app already reads `--surface-*`, `--fg-*`, `--accent*`, `--status-*`, `--tint-base`, `--syn-*`. No component learns theme names.
|
||||
|
||||
1. **`client/src/index.css`**
|
||||
- Light default block becomes `:root, [data-mode="light"]` (so a light preview can be scoped inside a dark page).
|
||||
- After the `.dark` block, one block per theme: dark ones `.dark[data-theme="<id>"]`, light ones `[data-theme="<id>"]` (placed after the defaults, same specificity as `[data-mode=light]`, later wins). Each overrides only what differs; `--tint-base` always = its `--surface-3`.
|
||||
- New `--diff-add` / `--diff-del` NOT needed: rows use existing `status-*` tokens.
|
||||
2. **`client/src/hooks/useTheme.ts`** → module-level store read via `useSyncExternalStore`, so Settings and Sidebar never disagree.
|
||||
- Storage: `theme` key now holds `dark|light|system` (old values stay valid → no migration); `agenthub.theme.dark` / `agenthub.theme.light` hold theme ids; unknown id ⇒ `default`.
|
||||
- `system` resolves via `matchMedia("(prefers-color-scheme: dark)")` and follows its `change` event.
|
||||
- Applies `.dark` class + `data-theme` attribute on `<html>` (attribute removed for `default`).
|
||||
- Keeps today's API (`theme` = resolved `dark|light`, `setTheme`, `toggleTheme` — toggle sets the explicit opposite mode) and adds `mode`, `setMode`, `themeFor`, `setThemeFor(mode, id)`, plus exported `THEMES` registry `{ dark: [...ids], light: [...ids] }`.
|
||||
3. **`client/src/pages/Settings.tsx`** — new first section `appearance` in `SETTINGS_SECTIONS`: a 3-button mode segment, then two grids (Dark themes / Light themes) of cards. Each card's swatch is a `<div data-mode|class=dark data-theme=id>` painting `bg-surface-0`, a `bg-surface-3` card, an accent dot and two syntax colours — the CSS vars re-scope inside it, so previews need zero duplicated hex. Selected card = accent ring + `aria-pressed`.
|
||||
4. **`client/src/components/Sidebar.tsx`** — expanded panel's theme grid gains **System** (3 buttons, Monitor icon); collapsed toggle unchanged.
|
||||
5. **`client/src/components/lanes/LaneDiffView.tsx`** `rowTint` → `bg-status-*/20` + a 3px inset left bar in the status colour; the `+`/`-` marker coloured `text-status-*` and bold. `highlight.ts`'s `add`/`del` classes use the same `/20` status tokens instead of fixed `emerald/red-500/10`.
|
||||
6. **i18n** `en`/`vi`: `nav.themeNames.system`, `settings.appearance.*` (title, mode, darkThemes, lightThemes, theme names).
|
||||
|
||||
## Revisions after the plan challenge
|
||||
1. Store keeps NO cached module state: `getSnapshot` reads localStorage + `matchMedia` and returns a primitive string; `subscribe` listens to a custom `agenthub-theme` event, `storage` (cross-tab) and the media-query `change`. No DOM work at import. DOM applied synchronously in the setter and in a `useLayoutEffect` (no one-frame flash).
|
||||
2. `window.matchMedia?.(...)` guarded (jsdom lacks it); the system test installs its own listener-capable mock.
|
||||
3. `<html>` carries `data-mode="dark|light"` too; light themes scoped `[data-mode="light"][data-theme=x]`, dark `.dark[data-theme=x]` — no source-order dependency.
|
||||
4. `--tint-base: var(--surface-3)` and `--syn-plain: var(--fg-primary)` declared once in each default block; theme blocks never set them.
|
||||
5. Diff emphasis `LaneDiffView.tsx:48` `bg-white/15` → `bg-fg-primary/15` (invisible on light otherwise).
|
||||
6. Swatch root sets `text-fg-primary` + `bg-surface-0`.
|
||||
7. Palettes pinned below; contrast checked with a small node script (white-on-accent, fg-muted on surface-3, status on its /20 chip) — dark themes held to the SAME bar the current dark default meets (its #0090ff is 3.3:1 under white text), light themes to ≥4.5:1.
|
||||
8. Appearance section lives in its own component `client/src/components/settings/AppearanceSettings.tsx`; Settings.tsx gains only the section entry + render.
|
||||
9. Sidebar: check the expanded panel width with 3 theme buttons; collapsed toggle moves System → the explicit opposite mode (documented).
|
||||
|
||||
## Palettes (hex; surface-0..5, border, border-light, accent, accent-hover, fg 1/2/muted, success/danger/warning; syntax)
|
||||
- **darcula**: #2b2b2b #303234 #36383a #3c3f41 #45494a #4e5254 · #4b4f51 #5e6366 · #3592c4 #4aa3d6 · #dcdcdc #a9b7c6 #8c9296 · #6aab73 #ff6b68 #e0b25c · keyword #cc7832 string #6a8759 number #6897bb comment #808080 function #ffc66d property #9876aa tag #e8bf6a builtin #8888c6 operator/punct #a9b7c6 add #6aab73 del #ff6b68
|
||||
- **apple-dark**: #161618 #1c1c1e #242426 #2c2c2e #363638 #3a3a3c · #38383a #48484a · #0a84ff #409cff · #f5f5f7 #aeaeb2 #8e8e93 · #30d158 #ff6961 #ffd60a · keyword #fc5fa3 string #fc6a5d number #d0bf69 comment #7f8c98 function #67b7a4 builtin #5dd8ff property #a167e6 tag #fc5fa3 operator/punct #a1a1a6 add #30d158 del #ff6961
|
||||
- **apple-light**: #e9e9ee #efeff4 #f5f5f8 #fbfbfd #fdfdfe #ffffff · #d1d1d6 #e5e5ea · #0066cc #0055b3 · #1d1d1f #515154 #6e6e73 · #1f7a36 #c5000f #8a5a00 · keyword #9b2393 string #c41a16 number #1c00cf comment #5d6c79 function #326d74 builtin #0b4f79 property #3900a0 tag #9b2393 operator/punct #4b4b50 add #1f7a36 del #c5000f
|
||||
- **intellij-light**: #eceef2 #f1f2f5 #f7f8fa #fdfdfe #ffffff #ffffff · #d3d5db #e4e6eb · #3574f0 #2e63cf · #1e1f22 #494b57 #6c707e · #1d7a35 #c01f2a #8f5f00 · keyword #0033b3 string #067d17 number #1750eb comment #707070 function #00627a builtin #000080 property #871094 tag #0033b3 operator/punct #4b4b4b add #067d17 del #c01f2a
|
||||
- **canvas**: #e3dccd #e8e2d4 #ede8dc #f3efe6 #f7f4ed #faf8f3 · #cbbfa6 #dcd3c0 · #7a5230 #663f22 · #2b2620 #5e554a #6b6155 · #3f6a2a #9f2f23 #7d5700 · keyword #8a3b12 string #4d6b1f number #9a5b00 comment #7d7262 function #7a5a00 builtin #2f5e6e property #3b6660 tag #8e2f2f operator #8a3050 punct #6b6155 add #4d6b1f del #9f2f23
|
||||
(Values that fail the contrast script are darkened/lightened until they pass; the final CSS is the source of truth.)
|
||||
|
||||
## Out of scope
|
||||
- Server-side persistence (theme stays a per-browser preference, like lane order).
|
||||
- ~~A no-flash inline boot script~~ — added after acceptance at the user's request (`client/index.html`).
|
||||
- User-defined/custom themes.
|
||||
- Surfaces that already bypass the CSS variables (they clash with today's light default too): the xterm terminal (no `theme`, TerminalView.tsx), Dashboard/workflow chart hardcoded hex (Dashboard.tsx, OrchestrationDAG.tsx, ErrorPropagationMap.tsx), ~415 raw Tailwind palette classes (toolStyle.ts, types.ts, …), `index.html`'s `text-gray-200` body.
|
||||
|
||||
## Tests
|
||||
- `hooks/__tests__/useTheme.test.ts`: existing 4 stay green; add — `system` follows a mocked `matchMedia` and its change event; `setThemeFor` sets `data-theme` only for the active mode and persists; unknown stored id ⇒ default (no attribute); two hook instances stay in sync.
|
||||
- Settings: a test that clicking a theme card sets `data-theme` / clicking System sets mode.
|
||||
- Snapshot diff for Settings/Sidebar reviewed, then regenerated deliberately.
|
||||
- `npm run test:client`, `tsc` via build, `check-headers.sh`. Manual: build + open `/settings`, screenshot each theme.
|
||||
|
||||
## Acceptance
|
||||
- [ ] Mode Dark/Light/System works and persists across reload; System follows the OS live.
|
||||
- [ ] Each of the 7 themes selectable; per-mode memory; sidebar toggle swaps between the two remembered themes.
|
||||
- [ ] Code syntax recolours per theme.
|
||||
- [ ] Diff +/- rows clearly distinguishable in every theme.
|
||||
- [ ] Docs (client README / CLAUDE.md marker) updated.
|
||||
Reference in New Issue
Block a user