@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.
- package/THIRD-PARTY-NOTICES.md +82 -0
- package/out/headless/index.mjs +91 -72
- package/out/headless/index.mjs.map +3 -3
- package/out/main/index.js +117 -72
- package/out/preload/index.js +6 -1
- package/out/renderer/assets/{cssMode-bQYoCKra.js → cssMode-B77M9HlN.js} +3 -3
- package/out/renderer/assets/{freemarker2-BS1ERQl_.js → freemarker2-oUT2zvyQ.js} +1 -1
- package/out/renderer/assets/{handlebars-B-ZEHRLr.js → handlebars-jCckoi5j.js} +1 -1
- package/out/renderer/assets/{html-lk9e1Lkl.js → html-kAgrAaKW.js} +1 -1
- package/out/renderer/assets/{htmlMode-Ds1eMYgM.js → htmlMode-DIWkIawy.js} +3 -3
- package/out/renderer/assets/{index-BpCORklm.css → index-3sFJEz7i.css} +787 -399
- package/out/renderer/assets/{index-BJNR4hRK.js → index-CN3UN7Tm.js} +431 -104
- package/out/renderer/assets/{javascript-D6OeziKX.js → javascript-B9GYUE-I.js} +2 -2
- package/out/renderer/assets/{jsonMode-t5SESE75.js → jsonMode-BzFKTZjJ.js} +3 -3
- package/out/renderer/assets/{liquid-B5Wjh0p_.js → liquid-Ccw-S_Y6.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-BEgiDCIW.js → lspLanguageFeatures-BE0g7ubH.js} +1 -1
- package/out/renderer/assets/{mdx-ByNAXh8t.js → mdx-oXeAA0qU.js} +1 -1
- package/out/renderer/assets/{python-CIFO0XQW.js → python-DlSX3TvA.js} +1 -1
- package/out/renderer/assets/{razor-LXHhGcNO.js → razor-ButO9S7r.js} +1 -1
- package/out/renderer/assets/{tsMode-WiO8CDaI.js → tsMode-CTCpE-67.js} +1 -1
- package/out/renderer/assets/{typescript-ZBQlq256.js → typescript-9o62WOnS.js} +1 -1
- package/out/renderer/assets/{xml-DBaLJlQU.js → xml-C4nOH2_D.js} +1 -1
- package/out/renderer/assets/{yaml-CId9skah.js → yaml-mXYgKoWW.js} +1 -1
- package/out/renderer/index.html +60 -3
- package/package.json +5 -4
- package/src/main/index.ts +54 -3
- package/src/main/orchestrator.ts +10 -2
- package/src/main/ticketStore.ts +100 -66
- package/src/preload/index.ts +13 -3
- package/src/renderer/App.tsx +4 -2
- package/src/renderer/components/CtlsurfPanel.tsx +1 -1
- package/src/renderer/components/EditorPanel.tsx +29 -1
- package/src/renderer/components/ObserverPanel.tsx +13 -6
- package/src/renderer/components/SettingsDialog.tsx +82 -0
- package/src/renderer/components/TerminalPanel.tsx +12 -22
- package/src/renderer/components/TicketPanel.tsx +33 -15
- package/src/renderer/index.html +58 -1
- package/src/renderer/lib/theme.ts +173 -0
- package/src/renderer/lib/themes.ts +126 -0
- 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=
|
|
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: '
|
|
48
|
-
warn: '
|
|
49
|
-
high: '
|
|
50
|
-
critical: '
|
|
49
|
+
info: 'var(--accent)',
|
|
50
|
+
warn: 'var(--warning)',
|
|
51
|
+
high: 'var(--sev-high)',
|
|
52
|
+
critical: 'var(--danger)',
|
|
51
53
|
}
|
|
52
|
-
const DIGEST_COLOR = '
|
|
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:
|
|
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: '
|
|
7
|
-
{ value: 'In Progress', color: '
|
|
8
|
-
{ value: 'Blocked', color: '
|
|
9
|
-
{ value: 'Done', color: '
|
|
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: '
|
|
13
|
-
{ value: 'Med', color: '
|
|
14
|
-
{ value: 'High', color: '
|
|
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 ?? '
|
|
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
|
-
|
|
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: ${
|
|
81
|
-
`- Agent Datastore page ID: ${
|
|
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:
|
|
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:
|
|
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.
|
package/src/renderer/index.html
CHANGED
|
@@ -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
|
+
}
|