@phenx-inc/ctlsurf 0.14.2 → 0.15.1

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 (40) hide show
  1. package/THIRD-PARTY-NOTICES.md +82 -0
  2. package/out/headless/index.mjs +91 -72
  3. package/out/headless/index.mjs.map +3 -3
  4. package/out/main/index.js +117 -72
  5. package/out/preload/index.js +6 -1
  6. package/out/renderer/assets/{cssMode-bQYoCKra.js → cssMode-B77M9HlN.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-BS1ERQl_.js → freemarker2-oUT2zvyQ.js} +1 -1
  8. package/out/renderer/assets/{handlebars-B-ZEHRLr.js → handlebars-jCckoi5j.js} +1 -1
  9. package/out/renderer/assets/{html-lk9e1Lkl.js → html-kAgrAaKW.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-Ds1eMYgM.js → htmlMode-DIWkIawy.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-CN3UN7Tm.js} +431 -104
  13. package/out/renderer/assets/{javascript-D6OeziKX.js → javascript-B9GYUE-I.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-t5SESE75.js → jsonMode-BzFKTZjJ.js} +3 -3
  15. package/out/renderer/assets/{liquid-B5Wjh0p_.js → liquid-Ccw-S_Y6.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-BEgiDCIW.js → lspLanguageFeatures-BE0g7ubH.js} +1 -1
  17. package/out/renderer/assets/{mdx-ByNAXh8t.js → mdx-oXeAA0qU.js} +1 -1
  18. package/out/renderer/assets/{python-CIFO0XQW.js → python-DlSX3TvA.js} +1 -1
  19. package/out/renderer/assets/{razor-LXHhGcNO.js → razor-ButO9S7r.js} +1 -1
  20. package/out/renderer/assets/{tsMode-WiO8CDaI.js → tsMode-CTCpE-67.js} +1 -1
  21. package/out/renderer/assets/{typescript-ZBQlq256.js → typescript-9o62WOnS.js} +1 -1
  22. package/out/renderer/assets/{xml-DBaLJlQU.js → xml-C4nOH2_D.js} +1 -1
  23. package/out/renderer/assets/{yaml-CId9skah.js → yaml-mXYgKoWW.js} +1 -1
  24. package/out/renderer/index.html +60 -3
  25. package/package.json +5 -4
  26. package/src/main/index.ts +54 -3
  27. package/src/main/orchestrator.ts +10 -2
  28. package/src/main/ticketStore.ts +100 -66
  29. package/src/preload/index.ts +13 -3
  30. package/src/renderer/App.tsx +4 -2
  31. package/src/renderer/components/CtlsurfPanel.tsx +1 -1
  32. package/src/renderer/components/EditorPanel.tsx +29 -1
  33. package/src/renderer/components/ObserverPanel.tsx +13 -6
  34. package/src/renderer/components/SettingsDialog.tsx +82 -0
  35. package/src/renderer/components/TerminalPanel.tsx +12 -22
  36. package/src/renderer/components/TicketPanel.tsx +33 -15
  37. package/src/renderer/index.html +58 -1
  38. package/src/renderer/lib/theme.ts +173 -0
  39. package/src/renderer/lib/themes.ts +126 -0
  40. package/src/renderer/styles.css +787 -399
@@ -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
@@ -24,6 +31,10 @@ interface Ticket {
24
31
  status: string
25
32
  priority: string
26
33
  created: string | null
34
+ // Owning datastore — tickets can live in more than one store per project,
35
+ // so edits and dispatch prompts must reference the row's own store.
36
+ blockId?: string
37
+ pageId?: string
27
38
  }
28
39
 
29
40
  type SaveState = { kind: 'idle' } | { kind: 'saving' } | { kind: 'error'; message: string }
@@ -75,10 +86,13 @@ function ticketPrompt(t: Ticket, refs: TicketRefs | null): string {
75
86
  'ctlsurf references (use the ctlsurf MCP tools to read details or update status):',
76
87
  `- Ticket row ID: ${t.id}`,
77
88
  )
78
- if (refs) {
89
+ // Prefer the ticket's own store — it may not be the project's primary one.
90
+ const blockId = t.blockId ?? refs?.blockId
91
+ const pageId = t.pageId ?? refs?.pageId
92
+ if (blockId && pageId) {
79
93
  lines.push(
80
- `- Tickets datastore block ID: ${refs.blockId}`,
81
- `- Agent Datastore page ID: ${refs.pageId}`,
94
+ `- Tickets datastore block ID: ${blockId}`,
95
+ `- Agent Datastore page ID: ${pageId}`,
82
96
  )
83
97
  }
84
98
  return lines.join('\n')
@@ -100,7 +114,7 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
100
114
  key={o.value}
101
115
  type="button"
102
116
  className={`ticket-seg ${active ? 'active' : ''}`}
103
- style={active ? { borderColor: o.color, color: o.color, background: `${o.color}1f` } : undefined}
117
+ style={active ? { borderColor: o.color, color: o.color, background: tint(o.color, 12) } : undefined}
104
118
  onClick={() => onChange(o.value)}
105
119
  >
106
120
  <span className="ticket-seg-dot" style={{ background: o.color }} />
@@ -132,7 +146,7 @@ function TicketCard({ ticket, onClick, onSend }: { ticket: Ticket; onClick: () =
132
146
  </div>
133
147
  <span
134
148
  className="ticket-card-pri"
135
- style={{ color: colorOf(PRIORITY_OPTIONS, ticket.priority), borderColor: `${colorOf(PRIORITY_OPTIONS, ticket.priority)}66` }}
149
+ style={{ color: colorOf(PRIORITY_OPTIONS, ticket.priority), borderColor: tint(colorOf(PRIORITY_OPTIONS, ticket.priority), 40) }}
136
150
  >
137
151
  {ticket.priority}
138
152
  </span>
@@ -170,6 +184,8 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
170
184
 
171
185
  // form state
172
186
  const [editingId, setEditingId] = useState<string | null>(null)
187
+ // Store the edited ticket came from, so the save lands in the right one.
188
+ const [editingBlockId, setEditingBlockId] = useState<string | null>(null)
173
189
  const [title, setTitle] = useState('')
174
190
  const [description, setDescription] = useState('')
175
191
  const [status, setStatus] = useState(STATUS_OPTIONS[0].value)
@@ -209,6 +225,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
209
225
 
210
226
  const openNewForm = useCallback(() => {
211
227
  setEditingId(null)
228
+ setEditingBlockId(null)
212
229
  setTitle('')
213
230
  setDescription('')
214
231
  setStatus(STATUS_OPTIONS[0].value)
@@ -219,6 +236,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
219
236
 
220
237
  const openEditForm = useCallback((t: Ticket) => {
221
238
  setEditingId(t.id)
239
+ setEditingBlockId(t.blockId ?? null)
222
240
  setTitle(t.title)
223
241
  setDescription(t.description)
224
242
  setStatus(STATUS_OPTIONS.some(o => o.value === t.status) ? t.status : STATUS_OPTIONS[0].value)
@@ -233,7 +251,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
233
251
  const payload = { title: title.trim(), description: description.trim(), status, priority }
234
252
  try {
235
253
  const r = editingId
236
- ? await window.worker.updateTicket(editingId, payload)
254
+ ? await window.worker.updateTicket(editingId, payload, editingBlockId ?? undefined)
237
255
  : await window.worker.addTicket(payload)
238
256
  if (r?.ok) {
239
257
  setView('list')
@@ -244,7 +262,7 @@ export function TicketPane({ onSendToTerminal }: { onSendToTerminal: (text: stri
244
262
  } catch (err: any) {
245
263
  setSave({ kind: 'error', message: err?.message || 'Failed to save ticket' })
246
264
  }
247
- }, [title, description, status, priority, editingId, save.kind, refreshList])
265
+ }, [title, description, status, priority, editingId, editingBlockId, save.kind, refreshList])
248
266
 
249
267
  // Only capture keys while editing a ticket, so list-view typing in the terminal
250
268
  // isn't hijacked. Esc returns to the list; Cmd/Ctrl+Enter saves.
@@ -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,6 +80,31 @@
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
109
  </head>
53
110
  <body>
@@ -0,0 +1,173 @@
1
+ import { useEffect, useState } from 'react'
2
+ import {
3
+ DEFAULT_DARK, DEFAULT_LIGHT, getTheme, isThemeId, type ThemeDef,
4
+ } from './themes'
5
+
6
+ // Appearance state.
7
+ //
8
+ // `sync` follows the OS and picks between the `light` / `dark` choices as it
9
+ // flips; with sync off, `theme` is pinned. Keeping the pair around while pinned
10
+ // means turning sync back on restores what you had rather than resetting.
11
+ //
12
+ // Stored in localStorage alongside the other renderer-local UI prefs
13
+ // (ctlsurf.floatingMicVisible, ctlsurf.voice.*) rather than in the main-process
14
+ // settings file — index.html has to read it synchronously before first paint,
15
+ // which IPC cannot do. The main process keeps a mirror for recovery; see below.
16
+ export interface Appearance {
17
+ sync: boolean
18
+ theme: string
19
+ light: string
20
+ dark: string
21
+ }
22
+
23
+ const KEY = 'ctlsurf.appearance'
24
+ const LEGACY_KEY = 'ctlsurf.theme'
25
+
26
+ export const DEFAULT_APPEARANCE: Appearance = {
27
+ sync: true,
28
+ theme: DEFAULT_DARK,
29
+ light: DEFAULT_LIGHT,
30
+ dark: DEFAULT_DARK,
31
+ }
32
+
33
+ function coerce(raw: unknown): Appearance | null {
34
+ if (!raw || typeof raw !== 'object') return null
35
+ const o = raw as Record<string, unknown>
36
+ // An unknown id (a theme removed in a later version) falls back rather than
37
+ // leaving the app with no palette at all.
38
+ return {
39
+ sync: o.sync !== false,
40
+ theme: isThemeId(o.theme) ? o.theme : DEFAULT_APPEARANCE.theme,
41
+ light: isThemeId(o.light) ? o.light : DEFAULT_APPEARANCE.light,
42
+ dark: isThemeId(o.dark) ? o.dark : DEFAULT_APPEARANCE.dark,
43
+ }
44
+ }
45
+
46
+ // null means "nothing stored", distinct from an explicit default — only the
47
+ // former may be replaced by the main-process mirror on boot.
48
+ function stored(): Appearance | null {
49
+ try {
50
+ const raw = localStorage.getItem(KEY)
51
+ if (raw) return coerce(JSON.parse(raw))
52
+ // Migrate the original system/light/dark preference.
53
+ const legacy = localStorage.getItem(LEGACY_KEY)
54
+ if (legacy === 'light') return { ...DEFAULT_APPEARANCE, sync: false, theme: DEFAULT_LIGHT }
55
+ if (legacy === 'dark') return { ...DEFAULT_APPEARANCE, sync: false, theme: DEFAULT_DARK }
56
+ if (legacy === 'system') return { ...DEFAULT_APPEARANCE }
57
+ } catch { /* private mode, or corrupt JSON */ }
58
+ return null
59
+ }
60
+
61
+ export function getAppearance(): Appearance {
62
+ return stored() ?? { ...DEFAULT_APPEARANCE }
63
+ }
64
+
65
+ const darkQuery = (): MediaQueryList | null =>
66
+ typeof window.matchMedia === 'function'
67
+ ? window.matchMedia('(prefers-color-scheme: dark)')
68
+ : null
69
+
70
+ export function prefersDark(): boolean {
71
+ return darkQuery()?.matches !== false
72
+ }
73
+
74
+ export function resolveThemeId(a: Appearance = getAppearance()): string {
75
+ if (!a.sync) return a.theme
76
+ return prefersDark() ? a.dark : a.light
77
+ }
78
+
79
+ // Subscribers get the resolved ThemeDef, so none of them has to redo the
80
+ // system-preference dance. Module-level: terminals outlive the components that
81
+ // render them (see the _terminals cache in TerminalPanel), so the xterm palette
82
+ // listener must not be tied to a component lifecycle.
83
+ type Listener = (theme: ThemeDef) => void
84
+ const listeners = new Set<Listener>()
85
+
86
+ export function subscribeTheme(fn: Listener): () => void {
87
+ listeners.add(fn)
88
+ return () => { listeners.delete(fn) }
89
+ }
90
+
91
+ export function currentTheme(): ThemeDef {
92
+ return getTheme(resolveThemeId())
93
+ }
94
+
95
+ function applyDom(): ThemeDef {
96
+ const theme = currentTheme()
97
+ document.documentElement.setAttribute('data-theme', theme.id)
98
+ for (const fn of listeners) fn(theme)
99
+ return theme
100
+ }
101
+
102
+ // Keep the native layer in step: form controls, scrollbars and the background
103
+ // Electron paints before the renderer loads are Chromium's, not our CSS. The
104
+ // main process also persists this, which is what makes the choice recoverable.
105
+ function syncNative(a: Appearance): void {
106
+ const theme = getTheme(resolveThemeId(a))
107
+ void window.worker?.setAppearance?.({
108
+ pref: a,
109
+ source: a.sync ? 'system' : theme.mode,
110
+ background: theme.background,
111
+ }).catch(() => { /* older main process */ })
112
+ }
113
+
114
+ export function setAppearance(next: Appearance): ThemeDef {
115
+ try { localStorage.setItem(KEY, JSON.stringify(next)) } catch { /* ignore */ }
116
+ syncNative(next)
117
+ return applyDom()
118
+ }
119
+
120
+ export function updateAppearance(patch: Partial<Appearance>): ThemeDef {
121
+ return setAppearance({ ...getAppearance(), ...patch })
122
+ }
123
+
124
+ // Re-resolve when the OS flips appearance, but only while syncing — a pinned
125
+ // theme should survive the OS changing under it.
126
+ darkQuery()?.addEventListener('change', () => {
127
+ const a = getAppearance()
128
+ if (a.sync) { applyDom(); syncNative(a) }
129
+ })
130
+
131
+ // index.html stamps data-theme before first paint so there is no flash; this
132
+ // re-runs it to pick up anything that changed since (and to seed listeners).
133
+ applyDom()
134
+
135
+ // localStorage is not as durable as it looks: a reset Chromium quota database,
136
+ // or two Electron instances sharing one --user-data-dir, silently empties it,
137
+ // which would reset the choice. The main process mirrors it into settings.json,
138
+ // so recover from there.
139
+ //
140
+ // Order matters — this reads before anything writes, which is why boot does not
141
+ // call syncNative() on this branch. Guarded on "nothing stored" at both ends so
142
+ // a real choice can never be clobbered by a late-arriving reply.
143
+ if (stored() === null) {
144
+ const restore = window.worker?.getAppearance?.()
145
+ if (restore) {
146
+ void restore
147
+ .then((r) => {
148
+ const recovered = coerce(r?.pref)
149
+ if (recovered && stored() === null) setAppearance(recovered)
150
+ })
151
+ .catch(() => { /* older main process */ })
152
+ }
153
+ } else {
154
+ syncNative(getAppearance())
155
+ }
156
+
157
+ export function useAppearance(): {
158
+ appearance: Appearance
159
+ theme: ThemeDef
160
+ update: (patch: Partial<Appearance>) => void
161
+ } {
162
+ const [appearance, setState] = useState<Appearance>(getAppearance)
163
+ const [theme, setTheme] = useState<ThemeDef>(currentTheme)
164
+
165
+ // Read state back off the store rather than tracking it locally, so a change
166
+ // made in one component reaches every other consumer.
167
+ useEffect(() => subscribeTheme((t) => {
168
+ setTheme(t)
169
+ setState(getAppearance())
170
+ }), [])
171
+
172
+ return { appearance, theme, update: updateAppearance }
173
+ }