@phenx-inc/ctlsurf 0.14.1 → 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 (40) hide show
  1. package/README.md +36 -0
  2. package/THIRD-PARTY-NOTICES.md +82 -0
  3. package/out/headless/index.mjs +13 -3
  4. package/out/headless/index.mjs.map +2 -2
  5. package/out/main/index.js +27 -0
  6. package/out/preload/index.js +5 -0
  7. package/out/renderer/assets/{cssMode-bQYoCKra.js → cssMode-CJR4iD-G.js} +3 -3
  8. package/out/renderer/assets/{freemarker2-BS1ERQl_.js → freemarker2-BRI9ISq9.js} +1 -1
  9. package/out/renderer/assets/{handlebars-B-ZEHRLr.js → handlebars-3hL6XIZI.js} +1 -1
  10. package/out/renderer/assets/{html-lk9e1Lkl.js → html-Dd5_vBoP.js} +1 -1
  11. package/out/renderer/assets/{htmlMode-Ds1eMYgM.js → htmlMode-DgNtlKcG.js} +3 -3
  12. package/out/renderer/assets/{index-BpCORklm.css → index-3sFJEz7i.css} +787 -399
  13. package/out/renderer/assets/{index-BJNR4hRK.js → index-5azC4yNn.js} +421 -99
  14. package/out/renderer/assets/{javascript-D6OeziKX.js → javascript-D0vPLEJg.js} +2 -2
  15. package/out/renderer/assets/{jsonMode-t5SESE75.js → jsonMode-DBvR0zMu.js} +3 -3
  16. package/out/renderer/assets/{liquid-B5Wjh0p_.js → liquid-CLoc6Tx7.js} +1 -1
  17. package/out/renderer/assets/{lspLanguageFeatures-BEgiDCIW.js → lspLanguageFeatures-CiaMaQoY.js} +1 -1
  18. package/out/renderer/assets/{mdx-ByNAXh8t.js → mdx-DTsq6tlv.js} +1 -1
  19. package/out/renderer/assets/{python-CIFO0XQW.js → python-D04DpZDr.js} +1 -1
  20. package/out/renderer/assets/{razor-LXHhGcNO.js → razor-Di6CJ6s8.js} +1 -1
  21. package/out/renderer/assets/{tsMode-WiO8CDaI.js → tsMode-mfv6mHOv.js} +1 -1
  22. package/out/renderer/assets/{typescript-ZBQlq256.js → typescript-CDA0Hhef.js} +1 -1
  23. package/out/renderer/assets/{xml-DBaLJlQU.js → xml-BU0voRuq.js} +1 -1
  24. package/out/renderer/assets/{yaml-CId9skah.js → yaml-qzFWnaZJ.js} +1 -1
  25. package/out/renderer/index.html +60 -3
  26. package/package.json +13 -3
  27. package/src/main/index.ts +52 -1
  28. package/src/main/orchestrator.ts +8 -0
  29. package/src/preload/index.ts +10 -0
  30. package/src/renderer/App.tsx +2 -0
  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 +17 -10
  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
@@ -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
+ }
@@ -0,0 +1,126 @@
1
+ // The theme registry.
2
+ //
3
+ // Palette values are transcribed from each project's published palette spec —
4
+ // no upstream code is copied. Scheme names identify which palette a theme
5
+ // reproduces; see THIRD-PARTY-NOTICES.md for licences and attribution.
6
+ //
7
+ // Each entry owns everything that can't be reached from CSS: the xterm palette
8
+ // (canvas, not DOM), the Monaco override colours, and the swatch shown in the
9
+ // picker. The chrome colours themselves live in styles.css, under a
10
+ // `:root[data-theme='<id>']` block keyed by the ids below — the two must stay
11
+ // in step, which is why `id` is the single source of truth for both.
12
+
13
+ export type ThemeMode = 'light' | 'dark'
14
+
15
+ // Structural subset of xterm's ITheme. Declared locally so this module stays
16
+ // importable from places that don't depend on xterm (the Settings dialog).
17
+ export interface XtermPalette {
18
+ background: string
19
+ foreground: string
20
+ cursor: string
21
+ selectionBackground: string
22
+ black: string; red: string; green: string; yellow: string
23
+ blue: string; magenta: string; cyan: string; white: string
24
+ brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
25
+ brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
26
+ }
27
+
28
+ export interface ThemeDef {
29
+ id: string
30
+ name: string
31
+ family: string
32
+ mode: ThemeMode
33
+ // Preview tile in Settings — drawn from literal values, because a swatch has
34
+ // to show its own palette rather than the active one.
35
+ swatch: { bg: string; surface: string; accent: string; text: string }
36
+ // Painted by Electron before the renderer loads, so it must match --bg-app.
37
+ background: string
38
+ terminal: XtermPalette
39
+ // Monaco ships only 'vs' / 'vs-dark'; these ride on top so the editor at
40
+ // least shares the scheme's background and gutter rather than clashing.
41
+ editor: { base: 'vs' | 'vs-dark'; background: string; foreground: string; lineHighlight: string; selection: string }
42
+ }
43
+
44
+ export const THEMES: ThemeDef[] = [
45
+ {
46
+ id: 'tokyo-night',
47
+ name: 'Tokyo Night',
48
+ family: 'Tokyo Night',
49
+ mode: 'dark',
50
+ swatch: { bg: '#1a1b26', surface: '#2a2b3d', accent: '#7aa2f7', text: '#a9b1d6' },
51
+ background: '#1a1b26',
52
+ terminal: {
53
+ background: '#1a1b26', foreground: '#a9b1d6', cursor: '#c0caf5', selectionBackground: '#33467c',
54
+ black: '#15161e', red: '#f7768e', green: '#9ece6a', yellow: '#e0af68',
55
+ blue: '#7aa2f7', magenta: '#bb9af7', cyan: '#7dcfff', white: '#a9b1d6',
56
+ brightBlack: '#414868', brightRed: '#f7768e', brightGreen: '#9ece6a', brightYellow: '#e0af68',
57
+ brightBlue: '#7aa2f7', brightMagenta: '#bb9af7', brightCyan: '#7dcfff', brightWhite: '#c0caf5',
58
+ },
59
+ editor: { base: 'vs-dark', background: '#1a1b26', foreground: '#a9b1d6', lineHighlight: '#1f2335', selection: '#33467c' },
60
+ },
61
+ {
62
+ id: 'tokyo-day',
63
+ name: 'Tokyo Night Day',
64
+ family: 'Tokyo Night',
65
+ mode: 'light',
66
+ swatch: { bg: '#e9eaef', surface: '#d4d7e2', accent: '#2e7de9', text: '#3a4060' },
67
+ background: '#e9eaef',
68
+ terminal: {
69
+ background: '#e9eaef', foreground: '#3760bf', cursor: '#3760bf', selectionBackground: '#b6c8f0',
70
+ black: '#b4b5b9', red: '#c64343', green: '#587539', yellow: '#8c6c3e',
71
+ blue: '#2e7de9', magenta: '#9854f1', cyan: '#007197', white: '#6172b0',
72
+ // Deliberately darker than `black`: remote TUIs use bright-black for dim
73
+ // text, which would vanish on a light background otherwise.
74
+ brightBlack: '#8990ad', brightRed: '#f52a65', brightGreen: '#4d7a1f', brightYellow: '#8f5e15',
75
+ brightBlue: '#1a68d6', brightMagenta: '#7847d1', brightCyan: '#006594', brightWhite: '#3760bf',
76
+ },
77
+ editor: { base: 'vs', background: '#e9eaef', foreground: '#3760bf', lineHighlight: '#dfe1e8', selection: '#b6c8f0' },
78
+ },
79
+ {
80
+ id: 'catppuccin-mocha',
81
+ name: 'Catppuccin Mocha',
82
+ family: 'Catppuccin',
83
+ mode: 'dark',
84
+ swatch: { bg: '#1e1e2e', surface: '#313244', accent: '#89b4fa', text: '#cdd6f4' },
85
+ background: '#1e1e2e',
86
+ terminal: {
87
+ background: '#1e1e2e', foreground: '#cdd6f4', cursor: '#f5e0dc', selectionBackground: '#45475a',
88
+ black: '#45475a', red: '#f38ba8', green: '#a6e3a1', yellow: '#f9e2af',
89
+ blue: '#89b4fa', magenta: '#f5c2e7', cyan: '#94e2d5', white: '#bac2de',
90
+ brightBlack: '#585b70', brightRed: '#f38ba8', brightGreen: '#a6e3a1', brightYellow: '#f9e2af',
91
+ brightBlue: '#89b4fa', brightMagenta: '#f5c2e7', brightCyan: '#94e2d5', brightWhite: '#a6adc8',
92
+ },
93
+ editor: { base: 'vs-dark', background: '#1e1e2e', foreground: '#cdd6f4', lineHighlight: '#272739', selection: '#45475a' },
94
+ },
95
+ {
96
+ id: 'catppuccin-latte',
97
+ name: 'Catppuccin Latte',
98
+ family: 'Catppuccin',
99
+ mode: 'light',
100
+ swatch: { bg: '#eff1f5', surface: '#ccd0da', accent: '#1e66f5', text: '#4c4f69' },
101
+ background: '#eff1f5',
102
+ terminal: {
103
+ background: '#eff1f5', foreground: '#4c4f69', cursor: '#dc8a78', selectionBackground: '#bcc0cc',
104
+ black: '#5c5f77', red: '#d20f39', green: '#40a02b', yellow: '#df8e1d',
105
+ blue: '#1e66f5', magenta: '#ea76cb', cyan: '#179299', white: '#acb0be',
106
+ brightBlack: '#6c6f85', brightRed: '#de293e', brightGreen: '#49af3d', brightYellow: '#eea416',
107
+ brightBlue: '#456eff', brightMagenta: '#dd63bd', brightCyan: '#2d9fa8', brightWhite: '#8c8fa1',
108
+ },
109
+ editor: { base: 'vs', background: '#eff1f5', foreground: '#4c4f69', lineHighlight: '#e6e9ef', selection: '#bcc0cc' },
110
+ },
111
+ ]
112
+
113
+ export const DEFAULT_DARK = 'tokyo-night'
114
+ export const DEFAULT_LIGHT = 'tokyo-day'
115
+
116
+ const byId = new Map(THEMES.map(t => [t.id, t]))
117
+
118
+ export function getTheme(id: string | null | undefined): ThemeDef {
119
+ return (id && byId.get(id)) || byId.get(DEFAULT_DARK)!
120
+ }
121
+
122
+ export function isThemeId(v: unknown): v is string {
123
+ return typeof v === 'string' && byId.has(v)
124
+ }
125
+
126
+ export const themesByMode = (mode: ThemeMode): ThemeDef[] => THEMES.filter(t => t.mode === mode)