@phenx-inc/ctlsurf 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/THIRD-PARTY-NOTICES.md +82 -0
  2. package/out/headless/index.mjs +3 -2
  3. package/out/headless/index.mjs.map +2 -2
  4. package/out/main/index.js +27 -0
  5. package/out/preload/index.js +5 -0
  6. package/out/renderer/assets/{cssMode-bQYoCKra.js → cssMode-CJR4iD-G.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-BS1ERQl_.js → freemarker2-BRI9ISq9.js} +1 -1
  8. package/out/renderer/assets/{handlebars-B-ZEHRLr.js → handlebars-3hL6XIZI.js} +1 -1
  9. package/out/renderer/assets/{html-lk9e1Lkl.js → html-Dd5_vBoP.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-Ds1eMYgM.js → htmlMode-DgNtlKcG.js} +3 -3
  11. package/out/renderer/assets/{index-BpCORklm.css → index-3sFJEz7i.css} +787 -399
  12. package/out/renderer/assets/{index-BJNR4hRK.js → index-5azC4yNn.js} +421 -99
  13. package/out/renderer/assets/{javascript-D6OeziKX.js → javascript-D0vPLEJg.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-t5SESE75.js → jsonMode-DBvR0zMu.js} +3 -3
  15. package/out/renderer/assets/{liquid-B5Wjh0p_.js → liquid-CLoc6Tx7.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-BEgiDCIW.js → lspLanguageFeatures-CiaMaQoY.js} +1 -1
  17. package/out/renderer/assets/{mdx-ByNAXh8t.js → mdx-DTsq6tlv.js} +1 -1
  18. package/out/renderer/assets/{python-CIFO0XQW.js → python-D04DpZDr.js} +1 -1
  19. package/out/renderer/assets/{razor-LXHhGcNO.js → razor-Di6CJ6s8.js} +1 -1
  20. package/out/renderer/assets/{tsMode-WiO8CDaI.js → tsMode-mfv6mHOv.js} +1 -1
  21. package/out/renderer/assets/{typescript-ZBQlq256.js → typescript-CDA0Hhef.js} +1 -1
  22. package/out/renderer/assets/{xml-DBaLJlQU.js → xml-BU0voRuq.js} +1 -1
  23. package/out/renderer/assets/{yaml-CId9skah.js → yaml-qzFWnaZJ.js} +1 -1
  24. package/out/renderer/index.html +60 -3
  25. package/package.json +5 -4
  26. package/src/main/index.ts +52 -1
  27. package/src/main/orchestrator.ts +8 -0
  28. package/src/preload/index.ts +10 -0
  29. package/src/renderer/App.tsx +2 -0
  30. package/src/renderer/components/CtlsurfPanel.tsx +1 -1
  31. package/src/renderer/components/EditorPanel.tsx +29 -1
  32. package/src/renderer/components/ObserverPanel.tsx +13 -6
  33. package/src/renderer/components/SettingsDialog.tsx +82 -0
  34. package/src/renderer/components/TerminalPanel.tsx +12 -22
  35. package/src/renderer/components/TicketPanel.tsx +17 -10
  36. package/src/renderer/index.html +58 -1
  37. package/src/renderer/lib/theme.ts +173 -0
  38. package/src/renderer/lib/themes.ts +126 -0
  39. package/src/renderer/styles.css +787 -399
@@ -1,5 +1,5 @@
1
- import { conf as conf$1, language as language$1 } from "./typescript-ZBQlq256.js";
2
- import "./index-BJNR4hRK.js";
1
+ import { conf as conf$1, language as language$1 } from "./typescript-CDA0Hhef.js";
2
+ import "./index-5azC4yNn.js";
3
3
  const conf = conf$1;
4
4
  const language = {
5
5
  // Set defaultToken to invalid to see what you do not tokenize yet
@@ -1,6 +1,6 @@
1
- import { c as createWebWorker, l as languages, e as editor } from "./index-BJNR4hRK.js";
2
- import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-BEgiDCIW.js";
3
- import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-BEgiDCIW.js";
1
+ import { c as createWebWorker, l as languages, e as editor } from "./index-5azC4yNn.js";
2
+ import { f as DocumentFormattingEditProvider, g as DocumentRangeFormattingEditProvider, C as CompletionAdapter, H as HoverAdapter, b as DocumentSymbolAdapter, d as DocumentColorAdapter, F as FoldingRangeAdapter, S as SelectionRangeAdapter, e as DiagnosticsAdapter } from "./lspLanguageFeatures-CiaMaQoY.js";
3
+ import { a, D, h, R, c, i, j, t, k } from "./lspLanguageFeatures-CiaMaQoY.js";
4
4
  const STOP_WHEN_IDLE_FOR = 2 * 60 * 1e3;
5
5
  class WorkerManager {
6
6
  constructor(defaults) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-5azC4yNn.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-BJNR4hRK.js";
1
+ import { R as Range$1, l as languages, e as editor, U as Uri, M as MarkerSeverity } from "./index-5azC4yNn.js";
2
2
  var DocumentUri;
3
3
  (function(DocumentUri2) {
4
4
  function is(value) {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-5azC4yNn.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["{/*", "*/}"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-5azC4yNn.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-5azC4yNn.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-BJNR4hRK.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-5azC4yNn.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-5azC4yNn.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-5azC4yNn.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-BJNR4hRK.js";
1
+ import { l as languages } from "./index-5azC4yNn.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -4,10 +4,42 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ctlsurf-worker</title>
7
+ <!-- Stamp the theme before the first paint. This has to be an inline,
8
+ synchronous script in <head>: reading the pref over IPC (or waiting for
9
+ the bundle) would let one frame of the wrong theme through, which reads
10
+ as a flash every launch. lib/theme.ts owns the same key afterwards. -->
11
+ <script>
12
+ (function () {
13
+ var DARK = 'tokyo-night', LIGHT = 'tokyo-day'
14
+ var a = null
15
+ try {
16
+ var raw = localStorage.getItem('ctlsurf.appearance')
17
+ if (raw) a = JSON.parse(raw)
18
+ else {
19
+ // Migrate the original system/light/dark preference. lib/theme.ts
20
+ // performs the same mapping; both must agree or the first frame
21
+ // disagrees with the second.
22
+ var legacy = localStorage.getItem('ctlsurf.theme')
23
+ if (legacy === 'light') a = { sync: false, theme: LIGHT }
24
+ else if (legacy === 'dark') a = { sync: false, theme: DARK }
25
+ }
26
+ } catch (e) { /* private mode, or corrupt JSON */ }
27
+ var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
28
+ var id
29
+ if (a && a.sync === false) id = a.theme
30
+ else if (a) id = prefersLight ? a.light : a.dark
31
+ else id = prefersLight ? LIGHT : DARK
32
+ document.documentElement.setAttribute('data-theme', id || (prefersLight ? LIGHT : DARK))
33
+ })()
34
+ </script>
7
35
  <!-- Boot splash styles are inlined here so they paint on the first frame,
8
36
  before the JS bundle and styles.css have loaded. Without this the window
9
- shows an empty dark pane while React mounts and the startup IPC runs. -->
37
+ shows an empty dark pane while React mounts and the startup IPC runs.
38
+ They cannot use the tokens in styles.css for the same reason, so the two
39
+ palettes are repeated here — keep them in step with :root there. -->
10
40
  <style>
41
+ :root { color-scheme: dark }
42
+ :root[data-theme='tokyo-day'], :root[data-theme='catppuccin-latte'] { color-scheme: light }
11
43
  #boot-splash {
12
44
  position: fixed;
13
45
  inset: 0;
@@ -48,9 +80,34 @@
48
80
  letter-spacing: 0.04em;
49
81
  color: #565f89;
50
82
  }
83
+ /* One rule per theme: the splash paints before styles.css exists, so it
84
+ cannot use the tokens. Keep in step with the :root blocks there. */
85
+ :root[data-theme='tokyo-day'] #boot-splash { background: #e9eaef; }
86
+ :root[data-theme='tokyo-day'] .boot-spinner {
87
+ border-color: rgba(46, 125, 233, 0.22);
88
+ border-top-color: #2e7de9;
89
+ }
90
+ :root[data-theme='tokyo-day'] .boot-splash-title { color: #1b1e33; }
91
+ :root[data-theme='tokyo-day'] .boot-splash-label { color: #6b7190; }
92
+
93
+ :root[data-theme='catppuccin-mocha'] #boot-splash { background: #1e1e2e; }
94
+ :root[data-theme='catppuccin-mocha'] .boot-spinner {
95
+ border-color: rgba(137, 180, 250, 0.22);
96
+ border-top-color: #89b4fa;
97
+ }
98
+ :root[data-theme='catppuccin-mocha'] .boot-splash-title { color: #cdd6f4; }
99
+ :root[data-theme='catppuccin-mocha'] .boot-splash-label { color: #7f849c; }
100
+
101
+ :root[data-theme='catppuccin-latte'] #boot-splash { background: #eff1f5; }
102
+ :root[data-theme='catppuccin-latte'] .boot-spinner {
103
+ border-color: rgba(30, 102, 245, 0.22);
104
+ border-top-color: #1e66f5;
105
+ }
106
+ :root[data-theme='catppuccin-latte'] .boot-splash-title { color: #2f3145; }
107
+ :root[data-theme='catppuccin-latte'] .boot-splash-label { color: #7c7f93; }
51
108
  </style>
52
- <script type="module" crossorigin src="./assets/index-BJNR4hRK.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-BpCORklm.css">
109
+ <script type="module" crossorigin src="./assets/index-5azC4yNn.js"></script>
110
+ <link rel="stylesheet" crossorigin href="./assets/index-3sFJEz7i.css">
54
111
  </head>
55
112
  <body>
56
113
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.14.2",
4
- "description": "Agent-agnostic terminal and desktop app for ctlsurf \u2014 run Claude Code, Codex, or any coding agent with live session logging and remote control",
3
+ "version": "0.15.0",
4
+ "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
7
7
  "ctlsurf": "./bin/ctlsurf-worker.js",
@@ -15,7 +15,8 @@
15
15
  "src/",
16
16
  "package.json",
17
17
  "electron-vite.config.ts",
18
- "tsconfig*.json"
18
+ "tsconfig*.json",
19
+ "THIRD-PARTY-NOTICES.md"
19
20
  ],
20
21
  "scripts": {
21
22
  "dev": "electron-vite dev",
@@ -84,4 +85,4 @@
84
85
  "bugs": {
85
86
  "email": "phenx@phenx.io"
86
87
  }
87
- }
88
+ }
package/src/main/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow, ipcMain, dialog, nativeImage, clipboard } from 'electron'
1
+ import { app, BrowserWindow, ipcMain, dialog, nativeImage, clipboard, nativeTheme } from 'electron'
2
2
  import path from 'path'
3
3
  import fs from 'fs'
4
4
 
@@ -233,12 +233,24 @@ const orchestrator = new Orchestrator(
233
233
  // ─── Window ───────────────────────────────────────
234
234
 
235
235
  function createWindow(): void {
236
+ // Replay last session's appearance choice before the window exists, so the
237
+ // frame Electron paints before the renderer loads is already the right
238
+ // colour. The renderer re-asserts this over app:setAppearance once booted.
239
+ const saved = orchestrator.settingsData
240
+ if (saved.themeSource === 'light' || saved.themeSource === 'dark' || saved.themeSource === 'system') {
241
+ nativeTheme.themeSource = saved.themeSource
242
+ }
243
+ const savedBg = typeof saved.themeBackground === 'string' && /^#[0-9a-fA-F]{6}$/.test(saved.themeBackground)
244
+ ? saved.themeBackground
245
+ : null
246
+
236
247
  mainWindow = new BrowserWindow({
237
248
  width: 1400,
238
249
  height: 900,
239
250
  minWidth: 600,
240
251
  minHeight: 400,
241
252
  title: 'ctlsurf-worker',
253
+ backgroundColor: savedBg ?? (nativeTheme.shouldUseDarkColors ? '#1a1b26' : '#e9eaef'),
242
254
  titleBarStyle: 'hiddenInset',
243
255
  icon: path.join(__dirname, '../../resources/icon.png'),
244
256
  webPreferences: {
@@ -388,6 +400,45 @@ ipcMain.handle('app:homePath', () => app.getPath('home'))
388
400
  ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
389
401
  ipcMain.handle('app:projectName', () => orchestrator.projectName)
390
402
 
403
+ // The renderer owns the theme preference (see lib/theme.ts) and mirrors it here
404
+ // so the parts Chromium paints for us — native form controls, scrollbars, and
405
+ // the window background between loads — match the app chrome. Also remembered
406
+ // so the next launch can paint the right backgroundColor before the renderer
407
+ // has booted.
408
+ // The renderer owns the appearance preference (see renderer/lib/theme.ts) and
409
+ // mirrors it here for two reasons: so Chromium paints native form controls,
410
+ // scrollbars and the pre-load window background to match, and so createWindow
411
+ // can pick the right backgroundColor on the *next* launch, before the renderer
412
+ // has booted. It is also the recovery source if localStorage is ever wiped.
413
+ ipcMain.handle('app:getAppearance', () => ({
414
+ pref: orchestrator.settingsData.appearance ?? null,
415
+ }))
416
+
417
+ ipcMain.handle('app:setAppearance', (_e, data: {
418
+ pref?: unknown
419
+ source?: 'system' | 'light' | 'dark'
420
+ background?: string
421
+ }) => {
422
+ const source = data?.source
423
+ if (source !== 'system' && source !== 'light' && source !== 'dark') return { ok: false }
424
+ nativeTheme.themeSource = source
425
+
426
+ const settings = orchestrator.settingsData
427
+ if (data.pref && typeof data.pref === 'object') {
428
+ settings.appearance = data.pref as Record<string, unknown>
429
+ }
430
+ settings.themeSource = source
431
+ // Only trust a plain hex colour — this value is handed straight to Electron.
432
+ if (typeof data.background === 'string' && /^#[0-9a-fA-F]{6}$/.test(data.background)) {
433
+ settings.themeBackground = data.background
434
+ if (mainWindow && !mainWindow.isDestroyed()) {
435
+ mainWindow.setBackgroundColor(data.background)
436
+ }
437
+ }
438
+ orchestrator.saveSettings()
439
+ return { ok: true }
440
+ })
441
+
391
442
  ipcMain.handle('app:browseCwd', async () => {
392
443
  if (!mainWindow) return null
393
444
  const result = await dialog.showOpenDialog(mainWindow, {
@@ -43,6 +43,14 @@ export interface SettingsData {
43
43
  // src/main/observer/. Requires an Anthropic API key for Tiers 1–2;
44
44
  // without one it still records the ledger, Tier-0 tags, and spans.
45
45
  observer?: ObserverSettings
46
+ // Mirror of the renderer's appearance preference (localStorage owns the
47
+ // authoritative copy — index.html has to read it before first paint). Kept
48
+ // here so createWindow can pick the right backgroundColor on the next launch
49
+ // before the renderer boots, and so the choice survives a wiped localStorage.
50
+ // Shape is owned by renderer/lib/theme.ts; the main process only round-trips it.
51
+ appearance?: Record<string, unknown>
52
+ themeSource?: 'system' | 'light' | 'dark'
53
+ themeBackground?: string
46
54
  }
47
55
 
48
56
  export interface OrchestratorEvents {
@@ -79,6 +79,16 @@ const api = {
79
79
  },
80
80
  browseCwd: (): Promise<string | null> =>
81
81
  ipcRenderer.invoke('app:browseCwd'),
82
+ // Mirror the renderer's appearance preference onto Chromium's nativeTheme and
83
+ // persist it. localStorage stays the source of truth (see renderer/lib/theme.ts);
84
+ // this copy backs the pre-load window background and localStorage recovery.
85
+ setAppearance: (data: {
86
+ pref: unknown
87
+ source: 'system' | 'light' | 'dark'
88
+ background: string
89
+ }): Promise<{ ok: boolean }> => ipcRenderer.invoke('app:setAppearance', data),
90
+ getAppearance: (): Promise<{ pref: unknown }> =>
91
+ ipcRenderer.invoke('app:getAppearance'),
82
92
 
83
93
  // Clipboard — write a rich-HTML table (+ plain-text fallback) for email paste.
84
94
  // `ok` reflects a read-back verification in the main process, not just that the
@@ -50,6 +50,8 @@ declare global {
50
50
  getProjectName: () => Promise<string | null>
51
51
  onProjectChanged: (callback: (name: string | null) => void) => () => void
52
52
  browseCwd: () => Promise<string | null>
53
+ setAppearance?: (data: { pref: unknown; source: 'system' | 'light' | 'dark'; background: string }) => Promise<{ ok: boolean }>
54
+ getAppearance?: () => Promise<{ pref: unknown }>
53
55
  getSetting: (key: string) => Promise<string | null>
54
56
  setSetting: (key: string, value: string) => Promise<{ ok: boolean }>
55
57
  getAllSettings: () => Promise<Record<string, string | null>>
@@ -190,7 +190,7 @@ export function CtlsurfPanel({ navigate }: CtlsurfPanelProps) {
190
190
  />
191
191
  ) : (
192
192
  <div className="ctlsurf-webview-loading">
193
- <span style={{ color: '#565f89', fontSize: '13px' }}>Loading ctlsurf...</span>
193
+ <span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Loading ctlsurf...</span>
194
194
  </div>
195
195
  )}
196
196
  </div>
@@ -6,6 +6,8 @@ import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
6
6
  import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
7
7
  import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
8
8
  import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
9
+ import { useAppearance } from '../lib/theme'
10
+ import { THEMES } from '../lib/themes'
9
11
 
10
12
  // Configure Monaco workers for Vite
11
13
  self.MonacoEnvironment = {
@@ -21,6 +23,31 @@ self.MonacoEnvironment = {
21
23
  // Use local monaco instead of CDN
22
24
  loader.config({ monaco })
23
25
 
26
+ // One Monaco theme per scheme. Monaco only ships 'vs' / 'vs-dark', so each
27
+ // scheme inherits the matching base (keeping its syntax highlighting) and
28
+ // overrides the surfaces, which are what would otherwise clash with the
29
+ // surrounding chrome. Registered at module load — Monaco resolves a theme by
30
+ // name at render time, so these must exist before the first <Editor> mounts.
31
+ for (const t of THEMES) {
32
+ monaco.editor.defineTheme(`ctlsurf-${t.id}`, {
33
+ base: t.editor.base,
34
+ inherit: true,
35
+ rules: [],
36
+ colors: {
37
+ 'editor.background': t.editor.background,
38
+ 'editor.foreground': t.editor.foreground,
39
+ 'editor.lineHighlightBackground': t.editor.lineHighlight,
40
+ 'editor.selectionBackground': t.editor.selection,
41
+ 'editorLineNumber.foreground': t.swatch.surface,
42
+ 'editorLineNumber.activeForeground': t.swatch.accent,
43
+ 'editorGutter.background': t.editor.background,
44
+ 'editorWidget.background': t.editor.lineHighlight,
45
+ 'editorCursor.foreground': t.terminal.cursor,
46
+ 'scrollbarSlider.background': `${t.swatch.surface}99`,
47
+ },
48
+ })
49
+ }
50
+
24
51
  interface FileEntry {
25
52
  name: string
26
53
  path: string
@@ -86,6 +113,7 @@ interface EditorPanelProps {
86
113
  }
87
114
 
88
115
  export function EditorPanel({ cwd }: EditorPanelProps) {
116
+ const { theme } = useAppearance()
89
117
  const [rootEntries, setRootEntries] = useState<FileEntry[]>([])
90
118
  const [openFile, setOpenFile] = useState<string | null>(null)
91
119
  const [fileContent, setFileContent] = useState<string>('')
@@ -208,7 +236,7 @@ export function EditorPanel({ cwd }: EditorPanelProps) {
208
236
  language={getLanguage(openFile)}
209
237
  value={fileContent}
210
238
  onChange={(val) => { setFileContent(val || ''); setModified(true) }}
211
- theme="vs-dark"
239
+ theme={`ctlsurf-${theme.id}`}
212
240
  options={{
213
241
  minimap: { enabled: false },
214
242
  fontSize: 13,
@@ -43,13 +43,15 @@ const SENSITIVITY_LEVELS: Array<{ value: Sensitivity; label: string; hint: strin
43
43
  { value: 'all', label: 'All signals', hint: 'Everything, including low-confidence observations' },
44
44
  ]
45
45
 
46
+ // Resolved from the token layer so both themes get a legible variant; see the
47
+ // :root blocks in styles.css.
46
48
  const SEVERITY_COLORS: Record<string, string> = {
47
- info: '#7aa2f7',
48
- warn: '#e0af68',
49
- high: '#ff9e64',
50
- critical: '#f7768e',
49
+ info: 'var(--accent)',
50
+ warn: 'var(--warning)',
51
+ high: 'var(--sev-high)',
52
+ critical: 'var(--danger)',
51
53
  }
52
- const DIGEST_COLOR = '#bb9af7'
54
+ const DIGEST_COLOR = 'var(--sev-digest)'
53
55
 
54
56
  function EyeIcon({ size = 16 }: { size?: number }) {
55
57
  return (
@@ -67,9 +69,14 @@ function formatTime(iso: string): string {
67
69
  return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
68
70
  }
69
71
 
72
+ // `${color}55` only worked while the colours were hex literals; token
73
+ // references have to be blended for real.
74
+ const tint = (color: string, pct: number): string =>
75
+ `color-mix(in srgb, ${color} ${pct}%, transparent)`
76
+
70
77
  function Chip({ color, label }: { color: string; label: string }) {
71
78
  return (
72
- <span className="obs-chip" style={{ color, borderColor: `${color}55`, background: `${color}14` }}>
79
+ <span className="obs-chip" style={{ color, borderColor: tint(color, 33), background: tint(color, 8) }}>
73
80
  <span className="obs-chip-dot" style={{ background: color }} />
74
81
  {label}
75
82
  </span>
@@ -1,4 +1,37 @@
1
1
  import { useState, useEffect } from 'react'
2
+ import { useAppearance } from '../lib/theme'
3
+ import { THEMES, themesByMode, type ThemeDef } from '../lib/themes'
4
+
5
+ // A tile per scheme. The swatch is drawn from the theme's own literal colours
6
+ // (not the active tokens) so each preview shows what it actually is.
7
+ function ThemeGrid({ themes, value, onChange }: {
8
+ themes: ThemeDef[]
9
+ value: string
10
+ onChange: (id: string) => void
11
+ }) {
12
+ return (
13
+ <div className="theme-options" role="radiogroup">
14
+ {themes.map(t => (
15
+ <button
16
+ key={t.id}
17
+ type="button"
18
+ role="radio"
19
+ aria-checked={value === t.id}
20
+ title={t.name}
21
+ className={`theme-option ${value === t.id ? 'active' : ''}`}
22
+ onClick={() => onChange(t.id)}
23
+ >
24
+ <span className="theme-swatch" style={{ background: t.swatch.bg }} aria-hidden="true">
25
+ <span className="theme-swatch-bar" style={{ background: t.swatch.surface }} />
26
+ <span className="theme-swatch-dot" style={{ background: t.swatch.accent }} />
27
+ <span className="theme-swatch-line" style={{ background: t.swatch.text }} />
28
+ </span>
29
+ <span className="theme-option-label">{t.name}</span>
30
+ </button>
31
+ ))}
32
+ </div>
33
+ )
34
+ }
2
35
 
3
36
  interface ProfileSummary {
4
37
  id: string
@@ -21,6 +54,7 @@ interface SettingsDialogProps {
21
54
  }
22
55
 
23
56
  export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
57
+ const { appearance, theme, update: updateAppearance } = useAppearance()
24
58
  const [profileList, setProfileList] = useState<ProfileListResponse | null>(null)
25
59
  const [activeProfileId, setActiveProfileId] = useState('production')
26
60
  const [editingId, setEditingId] = useState<string | null>(null)
@@ -157,6 +191,54 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
157
191
  <div className="settings-dialog" onClick={e => e.stopPropagation()}>
158
192
  <h3>Settings</h3>
159
193
 
194
+ {!editingId && (
195
+ // ─── Appearance (global) ───────────────
196
+ <div className="settings-section">
197
+ <h4>Appearance</h4>
198
+ <p className="settings-hint">
199
+ Applies everywhere, including the terminal and code editor.
200
+ </p>
201
+
202
+ <label className="settings-checkbox">
203
+ <input
204
+ type="checkbox"
205
+ checked={appearance.sync}
206
+ onChange={e => updateAppearance({ sync: e.target.checked })}
207
+ />
208
+ <span>Sync with system appearance</span>
209
+ <span className="settings-hint">
210
+ Switches between your light and dark picks as the OS changes.
211
+ </span>
212
+ </label>
213
+
214
+ {appearance.sync ? (
215
+ <>
216
+ <div className="theme-group-label">Light</div>
217
+ <ThemeGrid
218
+ themes={themesByMode('light')}
219
+ value={appearance.light}
220
+ onChange={id => updateAppearance({ light: id })}
221
+ />
222
+ <div className="theme-group-label">Dark</div>
223
+ <ThemeGrid
224
+ themes={themesByMode('dark')}
225
+ value={appearance.dark}
226
+ onChange={id => updateAppearance({ dark: id })}
227
+ />
228
+ <span className="settings-hint">
229
+ Currently {theme.name}.
230
+ </span>
231
+ </>
232
+ ) : (
233
+ <ThemeGrid
234
+ themes={THEMES}
235
+ value={appearance.theme}
236
+ onChange={id => updateAppearance({ theme: id })}
237
+ />
238
+ )}
239
+ </div>
240
+ )}
241
+
160
242
  {editingId ? (
161
243
  // ─── Profile Edit Form ─────────────────
162
244
  <div className="settings-section">
@@ -5,6 +5,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
5
5
  import '@xterm/xterm/css/xterm.css'
6
6
  import { convertToEmailTable } from '../lib/tableToHtml'
7
7
  import { speech } from '../lib/speech'
8
+ import { currentTheme, subscribeTheme } from '../lib/theme'
8
9
 
9
10
  // Show a transient toast in the active terminal panel. Broadcast as a window
10
11
  // event so module-level helpers (which have no component scope) can trigger it.
@@ -137,6 +138,16 @@ type TermState = {
137
138
 
138
139
  const _terminals = new Map<string, TermState>()
139
140
 
141
+ // Terminals are cached here and outlive the components that render them, so
142
+ // the palette swap is driven from module scope rather than a component effect.
143
+ // xterm paints to a canvas, so the CSS tokens in styles.css cannot reach it —
144
+ // each theme carries its own 16-colour palette in lib/themes.ts.
145
+ subscribeTheme((theme) => {
146
+ for (const state of _terminals.values()) {
147
+ state.terminal.options.theme = theme.terminal
148
+ }
149
+ })
150
+
140
151
  // Within a couple of lines of the bottom still counts as "at bottom", so a
141
152
  // small redraw delta doesn't strand the view just shy of the end.
142
153
  function isAtBottom(terminal: Terminal): boolean {
@@ -197,28 +208,7 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
197
208
  macOptionClickForcesSelection: true,
198
209
  fontSize: 14,
199
210
  fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
200
- theme: {
201
- background: '#1a1b26',
202
- foreground: '#a9b1d6',
203
- cursor: '#c0caf5',
204
- selectionBackground: '#33467C',
205
- black: '#15161e',
206
- red: '#f7768e',
207
- green: '#9ece6a',
208
- yellow: '#e0af68',
209
- blue: '#7aa2f7',
210
- magenta: '#bb9af7',
211
- cyan: '#7dcfff',
212
- white: '#a9b1d6',
213
- brightBlack: '#414868',
214
- brightRed: '#f7768e',
215
- brightGreen: '#9ece6a',
216
- brightYellow: '#e0af68',
217
- brightBlue: '#7aa2f7',
218
- brightMagenta: '#bb9af7',
219
- brightCyan: '#7dcfff',
220
- brightWhite: '#c0caf5'
221
- },
211
+ theme: currentTheme().terminal,
222
212
  scrollback: 10000,
223
213
  scrollOnUserInput: true,
224
214
  allowTransparency: false
@@ -2,20 +2,27 @@ import { useState, useEffect, useCallback } from 'react'
2
2
 
3
3
  interface Option { value: string; color: string }
4
4
 
5
+ // Token references rather than literals, so the pills follow the theme. They
6
+ // are also alpha-blended below via color-mix, which needs a var() to mix into.
5
7
  const STATUS_OPTIONS: Option[] = [
6
- { value: 'Open', color: '#7aa2f7' },
7
- { value: 'In Progress', color: '#e0af68' },
8
- { value: 'Blocked', color: '#f7768e' },
9
- { value: 'Done', color: '#9ece6a' },
8
+ { value: 'Open', color: 'var(--accent)' },
9
+ { value: 'In Progress', color: 'var(--warning)' },
10
+ { value: 'Blocked', color: 'var(--danger)' },
11
+ { value: 'Done', color: 'var(--success)' },
10
12
  ]
11
13
  const PRIORITY_OPTIONS: Option[] = [
12
- { value: 'Low', color: '#565f89' },
13
- { value: 'Med', color: '#e0af68' },
14
- { value: 'High', color: '#f7768e' },
14
+ { value: 'Low', color: 'var(--text-muted)' },
15
+ { value: 'Med', color: 'var(--warning)' },
16
+ { value: 'High', color: 'var(--danger)' },
15
17
  ]
16
18
 
17
19
  const colorOf = (opts: Option[], value: string): string =>
18
- opts.find(o => o.value === value)?.color ?? '#565f89'
20
+ opts.find(o => o.value === value)?.color ?? 'var(--text-muted)'
21
+
22
+ // `${color}1f` used to work because the colours were hex literals; tokens need
23
+ // a real blend.
24
+ const tint = (color: string, pct: number): string =>
25
+ `color-mix(in srgb, ${color} ${pct}%, transparent)`
19
26
 
20
27
  interface Ticket {
21
28
  id: string
@@ -100,7 +107,7 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
100
107
  key={o.value}
101
108
  type="button"
102
109
  className={`ticket-seg ${active ? 'active' : ''}`}
103
- style={active ? { borderColor: o.color, color: o.color, background: `${o.color}1f` } : undefined}
110
+ style={active ? { borderColor: o.color, color: o.color, background: tint(o.color, 12) } : undefined}
104
111
  onClick={() => onChange(o.value)}
105
112
  >
106
113
  <span className="ticket-seg-dot" style={{ background: o.color }} />
@@ -132,7 +139,7 @@ function TicketCard({ ticket, onClick, onSend }: { ticket: Ticket; onClick: () =
132
139
  </div>
133
140
  <span
134
141
  className="ticket-card-pri"
135
- style={{ color: colorOf(PRIORITY_OPTIONS, ticket.priority), borderColor: `${colorOf(PRIORITY_OPTIONS, ticket.priority)}66` }}
142
+ style={{ color: colorOf(PRIORITY_OPTIONS, ticket.priority), borderColor: tint(colorOf(PRIORITY_OPTIONS, ticket.priority), 40) }}
136
143
  >
137
144
  {ticket.priority}
138
145
  </span>