@phenx-inc/ctlsurf 0.3.13 → 0.3.14

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 (33) hide show
  1. package/bin/ctlsurf-worker.js +38 -22
  2. package/out/headless/index.mjs +247 -1
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +303 -46
  5. package/out/preload/index.js +5 -0
  6. package/out/renderer/assets/{cssMode-CYoo4t9f.js → cssMode-G_SDogBL.js} +3 -3
  7. package/out/renderer/assets/{freemarker2--UQnPZsn.js → freemarker2-BzEus0h2.js} +1 -1
  8. package/out/renderer/assets/{handlebars-DVDrmX0C.js → handlebars-Et995f6O.js} +1 -1
  9. package/out/renderer/assets/{html-D1-cXoLy.js → html-D4wgKxPD.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-f5nBuprq.js → htmlMode-DSxpefzL.js} +3 -3
  11. package/out/renderer/assets/{index-65hyKM_8.css → index-AQ346NMi.css} +386 -0
  12. package/out/renderer/assets/{index-D23nru43.js → index-ByJTqkiQ.js} +318 -22
  13. package/out/renderer/assets/{javascript-CcarFzBL.js → javascript-CzLoo8aq.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BvF-xK9U.js → jsonMode-BrwPy7fY.js} +3 -3
  15. package/out/renderer/assets/{liquid-CHLtUKl2.js → liquid-BsfPf6YG.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-B9aNeatS.js → lspLanguageFeatures-CxLZ421s.js} +1 -1
  17. package/out/renderer/assets/{mdx-HGDrkifZ.js → mdx-CPvHIsAR.js} +1 -1
  18. package/out/renderer/assets/{python-B_dPzjJ6.js → python-Dr7dCUjG.js} +1 -1
  19. package/out/renderer/assets/{razor-CHheM4ot.js → razor-a7zjD7Y3.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CdC3i1gG.js → tsMode-B7KLV2X6.js} +1 -1
  21. package/out/renderer/assets/{typescript-BX6guVRK.js → typescript-Cjuzf37q.js} +1 -1
  22. package/out/renderer/assets/{xml-CpS-pOPE.js → xml-Yz9xINtk.js} +1 -1
  23. package/out/renderer/assets/{yaml-Du0AjOHW.js → yaml-DtKnp5J0.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +1 -1
  26. package/src/main/ctlsurfApi.ts +11 -0
  27. package/src/main/index.ts +20 -0
  28. package/src/main/orchestrator.ts +37 -0
  29. package/src/main/ticketStore.ts +252 -0
  30. package/src/preload/index.ts +10 -0
  31. package/src/renderer/App.tsx +21 -0
  32. package/src/renderer/components/TicketPanel.tsx +308 -0
  33. package/src/renderer/styles.css +386 -0
@@ -0,0 +1,308 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+
3
+ interface Option { value: string; color: string }
4
+
5
+ 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' },
10
+ ]
11
+ const PRIORITY_OPTIONS: Option[] = [
12
+ { value: 'Low', color: '#565f89' },
13
+ { value: 'Med', color: '#e0af68' },
14
+ { value: 'High', color: '#f7768e' },
15
+ ]
16
+
17
+ const colorOf = (opts: Option[], value: string): string =>
18
+ opts.find(o => o.value === value)?.color ?? '#565f89'
19
+
20
+ interface Ticket {
21
+ id: string
22
+ title: string
23
+ description: string
24
+ status: string
25
+ priority: string
26
+ created: string | null
27
+ }
28
+
29
+ interface TicketPanelProps {
30
+ open: boolean
31
+ onClose: () => void
32
+ }
33
+
34
+ type SaveState = { kind: 'idle' } | { kind: 'saving' } | { kind: 'error'; message: string }
35
+ type View = 'list' | 'form'
36
+
37
+ function formatDate(iso: string | null): string {
38
+ if (!iso) return ''
39
+ const d = new Date(iso)
40
+ if (Number.isNaN(d.getTime())) return ''
41
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
42
+ }
43
+
44
+ function TagIcon({ size = 16 }: { size?: number }) {
45
+ return (
46
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none"
47
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
48
+ <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
49
+ <line x1="7" y1="7" x2="7.01" y2="7" />
50
+ </svg>
51
+ )
52
+ }
53
+
54
+ interface SegmentedProps {
55
+ options: Option[]
56
+ value: string
57
+ onChange: (v: string) => void
58
+ }
59
+
60
+ function Segmented({ options, value, onChange }: SegmentedProps) {
61
+ return (
62
+ <div className="ticket-segmented">
63
+ {options.map(o => {
64
+ const active = o.value === value
65
+ return (
66
+ <button
67
+ key={o.value}
68
+ type="button"
69
+ className={`ticket-seg ${active ? 'active' : ''}`}
70
+ style={active ? { borderColor: o.color, color: o.color, background: `${o.color}1f` } : undefined}
71
+ onClick={() => onChange(o.value)}
72
+ >
73
+ <span className="ticket-seg-dot" style={{ background: o.color }} />
74
+ {o.value}
75
+ </button>
76
+ )
77
+ })}
78
+ </div>
79
+ )
80
+ }
81
+
82
+ export function TicketPanel({ open, onClose }: TicketPanelProps) {
83
+ const [project, setProject] = useState<string | null>(null)
84
+ const [view, setView] = useState<View>('list')
85
+
86
+ // list state
87
+ const [tickets, setTickets] = useState<Ticket[]>([])
88
+ const [listLoading, setListLoading] = useState(false)
89
+ const [listError, setListError] = useState<string | null>(null)
90
+
91
+ // form state
92
+ const [editingId, setEditingId] = useState<string | null>(null)
93
+ const [title, setTitle] = useState('')
94
+ const [description, setDescription] = useState('')
95
+ const [status, setStatus] = useState(STATUS_OPTIONS[0].value)
96
+ const [priority, setPriority] = useState('Med')
97
+ const [save, setSave] = useState<SaveState>({ kind: 'idle' })
98
+
99
+ const refreshList = useCallback(async () => {
100
+ setListLoading(true)
101
+ setListError(null)
102
+ try {
103
+ const r = await window.worker.listTickets()
104
+ if (r?.ok) setTickets(r.tickets || [])
105
+ else { setTickets([]); setListError(r?.error || 'Failed to load tickets') }
106
+ } catch (err: any) {
107
+ setTickets([])
108
+ setListError(err?.message || 'Failed to load tickets')
109
+ } finally {
110
+ setListLoading(false)
111
+ }
112
+ }, [])
113
+
114
+ // On open: reset to the list and (re)load project + tickets.
115
+ useEffect(() => {
116
+ if (!open) return
117
+ setView('list')
118
+ let cancelled = false
119
+ window.worker.getTicketProject()
120
+ .then(r => { if (!cancelled) setProject(r?.name ?? null) })
121
+ .catch(() => { if (!cancelled) setProject(null) })
122
+ void refreshList()
123
+ return () => { cancelled = true }
124
+ }, [open, refreshList])
125
+
126
+ const openNewForm = useCallback(() => {
127
+ setEditingId(null)
128
+ setTitle('')
129
+ setDescription('')
130
+ setStatus(STATUS_OPTIONS[0].value)
131
+ setPriority('Med')
132
+ setSave({ kind: 'idle' })
133
+ setView('form')
134
+ }, [])
135
+
136
+ const openEditForm = useCallback((t: Ticket) => {
137
+ setEditingId(t.id)
138
+ setTitle(t.title)
139
+ setDescription(t.description)
140
+ setStatus(STATUS_OPTIONS.some(o => o.value === t.status) ? t.status : STATUS_OPTIONS[0].value)
141
+ setPriority(PRIORITY_OPTIONS.some(o => o.value === t.priority) ? t.priority : 'Med')
142
+ setSave({ kind: 'idle' })
143
+ setView('form')
144
+ }, [])
145
+
146
+ const handleSave = useCallback(async () => {
147
+ if (!title.trim() || save.kind === 'saving') return
148
+ setSave({ kind: 'saving' })
149
+ const payload = { title: title.trim(), description: description.trim(), status, priority }
150
+ try {
151
+ const r = editingId
152
+ ? await window.worker.updateTicket(editingId, payload)
153
+ : await window.worker.addTicket(payload)
154
+ if (r?.ok) {
155
+ setView('list')
156
+ void refreshList()
157
+ } else {
158
+ setSave({ kind: 'error', message: r?.error || 'Failed to save ticket' })
159
+ }
160
+ } catch (err: any) {
161
+ setSave({ kind: 'error', message: err?.message || 'Failed to save ticket' })
162
+ }
163
+ }, [title, description, status, priority, editingId, save.kind, refreshList])
164
+
165
+ // Esc: form → back to list, list → close. Cmd/Ctrl+Enter saves in the form.
166
+ useEffect(() => {
167
+ if (!open) return
168
+ const onKey = (e: KeyboardEvent) => {
169
+ if (e.key === 'Escape') {
170
+ e.preventDefault()
171
+ if (view === 'form') setView('list')
172
+ else onClose()
173
+ } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && view === 'form') {
174
+ e.preventDefault()
175
+ void handleSave()
176
+ }
177
+ }
178
+ window.addEventListener('keydown', onKey)
179
+ return () => window.removeEventListener('keydown', onKey)
180
+ }, [open, view, onClose, handleSave])
181
+
182
+ return (
183
+ <>
184
+ <div className={`ticket-scrim ${open ? 'open' : ''}`} onClick={onClose} />
185
+ <div className={`ticket-drawer ${open ? 'open' : ''}`} role="dialog" aria-label="Tickets">
186
+ <div className="ticket-drawer-header">
187
+ <div className="ticket-drawer-heading">
188
+ {view === 'form' ? (
189
+ <button className="ticket-drawer-back" onClick={() => setView('list')} title="Back (Esc)">‹</button>
190
+ ) : (
191
+ <span className="ticket-drawer-badge"><TagIcon size={16} /></span>
192
+ )}
193
+ <div className="ticket-drawer-headtext">
194
+ <span className="ticket-drawer-title">
195
+ {view === 'form' ? (editingId ? 'Edit ticket' : 'New ticket') : 'Tickets'}
196
+ </span>
197
+ <span className="ticket-drawer-subtitle">
198
+ {project
199
+ ? <>in <strong>{project}</strong></>
200
+ : <span className="ticket-drawer-warn">no ctlsurf project for this tab</span>}
201
+ </span>
202
+ </div>
203
+ </div>
204
+ <button className="ticket-drawer-close" onClick={onClose} title="Close">×</button>
205
+ </div>
206
+
207
+ {view === 'list' ? (
208
+ <>
209
+ <div className="ticket-list-body">
210
+ {listLoading && <div className="ticket-list-msg">Loading tickets…</div>}
211
+ {!listLoading && listError && (
212
+ <div className="ticket-drawer-error">⚠ {listError}</div>
213
+ )}
214
+ {!listLoading && !listError && tickets.length === 0 && (
215
+ <div className="ticket-empty">
216
+ <span className="ticket-empty-icon"><TagIcon size={26} /></span>
217
+ <span className="ticket-empty-title">No tickets yet</span>
218
+ <span className="ticket-empty-hint">Log the first one for this project.</span>
219
+ </div>
220
+ )}
221
+ {!listLoading && tickets.map(t => (
222
+ <button key={t.id} className="ticket-card" onClick={() => openEditForm(t)}>
223
+ <span className="ticket-card-bar" style={{ background: colorOf(STATUS_OPTIONS, t.status) }} />
224
+ <div className="ticket-card-main">
225
+ <span className="ticket-card-title">{t.title || 'Untitled'}</span>
226
+ <div className="ticket-card-meta">
227
+ <span className="ticket-card-status" style={{ color: colorOf(STATUS_OPTIONS, t.status) }}>
228
+ <span className="ticket-seg-dot" style={{ background: colorOf(STATUS_OPTIONS, t.status) }} />
229
+ {t.status}
230
+ </span>
231
+ {t.created && <span className="ticket-card-date">{formatDate(t.created)}</span>}
232
+ </div>
233
+ </div>
234
+ <span
235
+ className="ticket-card-pri"
236
+ style={{ color: colorOf(PRIORITY_OPTIONS, t.priority), borderColor: `${colorOf(PRIORITY_OPTIONS, t.priority)}66` }}
237
+ >
238
+ {t.priority}
239
+ </span>
240
+ </button>
241
+ ))}
242
+ </div>
243
+ <div className="ticket-drawer-footer">
244
+ <span className="ticket-drawer-hint">
245
+ {tickets.length > 0 && `${tickets.length} ticket${tickets.length === 1 ? '' : 's'}`}
246
+ </span>
247
+ <button className="ticket-btn-primary" onClick={openNewForm}>+ New ticket</button>
248
+ </div>
249
+ </>
250
+ ) : (
251
+ <>
252
+ <div className="ticket-drawer-body">
253
+ <label className="ticket-field">
254
+ <span className="ticket-field-label">Title</span>
255
+ <input
256
+ type="text"
257
+ className="ticket-input-title"
258
+ value={title}
259
+ autoFocus
260
+ placeholder="What needs doing?"
261
+ onChange={e => setTitle(e.target.value)}
262
+ />
263
+ </label>
264
+
265
+ <label className="ticket-field">
266
+ <span className="ticket-field-label">Description</span>
267
+ <textarea
268
+ value={description}
269
+ rows={5}
270
+ placeholder="Add detail, context, links…"
271
+ onChange={e => setDescription(e.target.value)}
272
+ />
273
+ </label>
274
+
275
+ <div className="ticket-field">
276
+ <span className="ticket-field-label">Status</span>
277
+ <Segmented options={STATUS_OPTIONS} value={status} onChange={setStatus} />
278
+ </div>
279
+
280
+ <div className="ticket-field">
281
+ <span className="ticket-field-label">Priority</span>
282
+ <Segmented options={PRIORITY_OPTIONS} value={priority} onChange={setPriority} />
283
+ </div>
284
+
285
+ {save.kind === 'error' && (
286
+ <div className="ticket-drawer-error">⚠ {save.message}</div>
287
+ )}
288
+ </div>
289
+
290
+ <div className="ticket-drawer-footer">
291
+ <span className="ticket-drawer-hint">⌘↵ to save</span>
292
+ <div className="ticket-drawer-footer-btns">
293
+ <button className="ticket-btn-secondary" onClick={() => setView('list')}>Cancel</button>
294
+ <button
295
+ className="ticket-btn-primary"
296
+ onClick={handleSave}
297
+ disabled={!title.trim() || save.kind === 'saving'}
298
+ >
299
+ {save.kind === 'saving' ? 'Saving…' : editingId ? 'Save changes' : 'Save ticket'}
300
+ </button>
301
+ </div>
302
+ </div>
303
+ </>
304
+ )}
305
+ </div>
306
+ </>
307
+ )
308
+ }
@@ -1027,3 +1027,389 @@ html, body, #root {
1027
1027
  display: flex;
1028
1028
  gap: 4px;
1029
1029
  }
1030
+
1031
+ /* ─── Ticket drawer ──────────────────────────────── */
1032
+
1033
+ .ticket-scrim {
1034
+ position: fixed;
1035
+ inset: 0;
1036
+ background: rgba(13, 14, 22, 0.55);
1037
+ backdrop-filter: blur(2px);
1038
+ opacity: 0;
1039
+ pointer-events: none;
1040
+ transition: opacity 0.2s ease;
1041
+ z-index: 90;
1042
+ }
1043
+ .ticket-scrim.open {
1044
+ opacity: 1;
1045
+ pointer-events: auto;
1046
+ }
1047
+
1048
+ .ticket-drawer {
1049
+ position: fixed;
1050
+ top: 0;
1051
+ right: 0;
1052
+ bottom: 0;
1053
+ width: 380px;
1054
+ background: linear-gradient(180deg, #20243a 0%, #1b1e2e 100%);
1055
+ border-left: 1px solid #343860;
1056
+ box-shadow: -16px 0 48px rgba(0, 0, 0, 0.5);
1057
+ display: flex;
1058
+ flex-direction: column;
1059
+ transform: translateX(100%);
1060
+ transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1);
1061
+ z-index: 100;
1062
+ }
1063
+ .ticket-drawer.open {
1064
+ transform: translateX(0);
1065
+ }
1066
+
1067
+ /* ── header ── */
1068
+ .ticket-drawer-header {
1069
+ display: flex;
1070
+ align-items: flex-start;
1071
+ justify-content: space-between;
1072
+ padding: 16px 16px 14px;
1073
+ border-bottom: 1px solid #2a2b3d;
1074
+ }
1075
+ .ticket-drawer-heading {
1076
+ display: flex;
1077
+ gap: 11px;
1078
+ align-items: center;
1079
+ }
1080
+ .ticket-drawer-badge {
1081
+ width: 34px;
1082
+ height: 34px;
1083
+ border-radius: 9px;
1084
+ background: rgba(122, 162, 247, 0.14);
1085
+ border: 1px solid rgba(122, 162, 247, 0.3);
1086
+ display: flex;
1087
+ align-items: center;
1088
+ justify-content: center;
1089
+ font-size: 16px;
1090
+ flex-shrink: 0;
1091
+ }
1092
+ .ticket-drawer-headtext {
1093
+ display: flex;
1094
+ flex-direction: column;
1095
+ gap: 1px;
1096
+ }
1097
+ .ticket-drawer-title {
1098
+ font-size: 14px;
1099
+ font-weight: 650;
1100
+ color: #d5dbf5;
1101
+ letter-spacing: 0.01em;
1102
+ }
1103
+ .ticket-drawer-subtitle {
1104
+ font-size: 11.5px;
1105
+ color: #6b739b;
1106
+ }
1107
+ .ticket-drawer-subtitle strong {
1108
+ color: #7aa2f7;
1109
+ font-weight: 600;
1110
+ }
1111
+ .ticket-drawer-warn {
1112
+ color: #e0af68;
1113
+ }
1114
+ .ticket-drawer-close {
1115
+ background: none;
1116
+ border: none;
1117
+ color: #565f89;
1118
+ font-size: 20px;
1119
+ line-height: 1;
1120
+ cursor: pointer;
1121
+ padding: 2px 6px;
1122
+ border-radius: 6px;
1123
+ transition: background 0.12s ease, color 0.12s ease;
1124
+ }
1125
+ .ticket-drawer-close:hover {
1126
+ color: #d5dbf5;
1127
+ background: rgba(255, 255, 255, 0.06);
1128
+ }
1129
+
1130
+ /* ── body ── */
1131
+ .ticket-drawer-body {
1132
+ flex: 1;
1133
+ overflow-y: auto;
1134
+ padding: 16px;
1135
+ display: flex;
1136
+ flex-direction: column;
1137
+ gap: 16px;
1138
+ }
1139
+
1140
+ .ticket-field {
1141
+ display: flex;
1142
+ flex-direction: column;
1143
+ gap: 7px;
1144
+ }
1145
+ .ticket-field-label {
1146
+ font-size: 10.5px;
1147
+ font-weight: 600;
1148
+ color: #6b739b;
1149
+ text-transform: uppercase;
1150
+ letter-spacing: 0.07em;
1151
+ }
1152
+
1153
+ .ticket-field input,
1154
+ .ticket-field textarea {
1155
+ background: #15161f;
1156
+ border: 1px solid #2a2b3d;
1157
+ border-radius: 8px;
1158
+ color: #d5dbf5;
1159
+ font-size: 13px;
1160
+ font-family: inherit;
1161
+ padding: 9px 11px;
1162
+ outline: none;
1163
+ transition: border-color 0.14s ease, box-shadow 0.14s ease;
1164
+ }
1165
+ .ticket-field input::placeholder,
1166
+ .ticket-field textarea::placeholder {
1167
+ color: #4a4f6e;
1168
+ }
1169
+ .ticket-field input:focus,
1170
+ .ticket-field textarea:focus {
1171
+ border-color: #7aa2f7;
1172
+ box-shadow: 0 0 0 3px rgba(122, 162, 247, 0.13);
1173
+ }
1174
+ .ticket-input-title {
1175
+ font-size: 14px !important;
1176
+ font-weight: 550;
1177
+ }
1178
+ .ticket-field textarea {
1179
+ resize: vertical;
1180
+ line-height: 1.5;
1181
+ }
1182
+
1183
+ /* ── segmented pill controls ── */
1184
+ .ticket-segmented {
1185
+ display: flex;
1186
+ flex-wrap: wrap;
1187
+ gap: 6px;
1188
+ }
1189
+ .ticket-seg {
1190
+ display: inline-flex;
1191
+ align-items: center;
1192
+ gap: 6px;
1193
+ background: #15161f;
1194
+ border: 1px solid #2a2b3d;
1195
+ border-radius: 999px;
1196
+ color: #8088ac;
1197
+ font-size: 12px;
1198
+ font-family: inherit;
1199
+ font-weight: 550;
1200
+ padding: 5px 11px 5px 9px;
1201
+ cursor: pointer;
1202
+ transition: background 0.13s ease, border-color 0.13s ease, color 0.13s ease;
1203
+ }
1204
+ .ticket-seg:hover {
1205
+ border-color: #3b3d57;
1206
+ color: #c0caf5;
1207
+ }
1208
+ .ticket-seg-dot {
1209
+ width: 7px;
1210
+ height: 7px;
1211
+ border-radius: 50%;
1212
+ flex-shrink: 0;
1213
+ }
1214
+ .ticket-seg.active {
1215
+ font-weight: 650;
1216
+ }
1217
+
1218
+ .ticket-drawer-error {
1219
+ background: rgba(247, 118, 142, 0.13);
1220
+ border: 1px solid rgba(247, 118, 142, 0.38);
1221
+ color: #f7768e;
1222
+ font-size: 12px;
1223
+ border-radius: 8px;
1224
+ padding: 8px 10px;
1225
+ }
1226
+
1227
+ /* ── footer ── */
1228
+ .ticket-drawer-footer {
1229
+ display: flex;
1230
+ align-items: center;
1231
+ justify-content: space-between;
1232
+ gap: 8px;
1233
+ padding: 12px 16px;
1234
+ border-top: 1px solid #2a2b3d;
1235
+ background: rgba(13, 14, 22, 0.35);
1236
+ }
1237
+ .ticket-drawer-hint {
1238
+ font-size: 11px;
1239
+ color: #565f89;
1240
+ }
1241
+ .ticket-drawer-footer-btns {
1242
+ display: flex;
1243
+ gap: 8px;
1244
+ }
1245
+ .ticket-btn-secondary,
1246
+ .ticket-btn-primary {
1247
+ font-size: 12.5px;
1248
+ font-family: inherit;
1249
+ font-weight: 600;
1250
+ border-radius: 8px;
1251
+ padding: 8px 14px;
1252
+ cursor: pointer;
1253
+ transition: background 0.13s ease, border-color 0.13s ease, transform 0.06s ease;
1254
+ }
1255
+ .ticket-btn-secondary {
1256
+ background: transparent;
1257
+ color: #c0caf5;
1258
+ border: 1px solid #2a2b3d;
1259
+ }
1260
+ .ticket-btn-secondary:hover {
1261
+ background: rgba(255, 255, 255, 0.05);
1262
+ border-color: #3b3d57;
1263
+ }
1264
+ .ticket-btn-primary {
1265
+ background: linear-gradient(180deg, #8db0fa 0%, #7aa2f7 100%);
1266
+ color: #11131f;
1267
+ border: 1px solid #7aa2f7;
1268
+ }
1269
+ .ticket-btn-primary:hover:not(:disabled) {
1270
+ background: linear-gradient(180deg, #9bbcfb 0%, #89b4fa 100%);
1271
+ }
1272
+ .ticket-btn-primary:active:not(:disabled) {
1273
+ transform: translateY(1px);
1274
+ }
1275
+ .ticket-btn-primary:disabled {
1276
+ opacity: 0.4;
1277
+ cursor: not-allowed;
1278
+ }
1279
+
1280
+ /* ── titlebar tickets icon ── */
1281
+ .ticket-tag-icon {
1282
+ display: inline-block;
1283
+ vertical-align: -2px;
1284
+ margin-right: 5px;
1285
+ }
1286
+
1287
+ /* ── drawer badge / back button ── */
1288
+ .ticket-drawer-badge {
1289
+ color: #7aa2f7;
1290
+ }
1291
+ .ticket-drawer-back {
1292
+ width: 34px;
1293
+ height: 34px;
1294
+ border-radius: 9px;
1295
+ background: rgba(122, 162, 247, 0.1);
1296
+ border: 1px solid rgba(122, 162, 247, 0.28);
1297
+ color: #7aa2f7;
1298
+ font-size: 22px;
1299
+ line-height: 1;
1300
+ cursor: pointer;
1301
+ flex-shrink: 0;
1302
+ transition: background 0.12s ease;
1303
+ }
1304
+ .ticket-drawer-back:hover {
1305
+ background: rgba(122, 162, 247, 0.2);
1306
+ }
1307
+
1308
+ /* ── ticket list ── */
1309
+ .ticket-list-body {
1310
+ flex: 1;
1311
+ overflow-y: auto;
1312
+ padding: 12px;
1313
+ display: flex;
1314
+ flex-direction: column;
1315
+ gap: 7px;
1316
+ }
1317
+ .ticket-list-msg {
1318
+ color: #6b739b;
1319
+ font-size: 12.5px;
1320
+ padding: 16px 4px;
1321
+ text-align: center;
1322
+ }
1323
+
1324
+ .ticket-empty {
1325
+ display: flex;
1326
+ flex-direction: column;
1327
+ align-items: center;
1328
+ gap: 5px;
1329
+ padding: 48px 16px;
1330
+ text-align: center;
1331
+ }
1332
+ .ticket-empty-icon {
1333
+ color: #3b3d57;
1334
+ margin-bottom: 4px;
1335
+ }
1336
+ .ticket-empty-title {
1337
+ font-size: 13.5px;
1338
+ font-weight: 600;
1339
+ color: #8088ac;
1340
+ }
1341
+ .ticket-empty-hint {
1342
+ font-size: 12px;
1343
+ color: #565f89;
1344
+ }
1345
+
1346
+ /* ── ticket card ── */
1347
+ .ticket-card {
1348
+ display: flex;
1349
+ align-items: stretch;
1350
+ gap: 10px;
1351
+ background: #15161f;
1352
+ border: 1px solid #262a3f;
1353
+ border-radius: 10px;
1354
+ padding: 10px 11px 10px 0;
1355
+ cursor: pointer;
1356
+ text-align: left;
1357
+ font-family: inherit;
1358
+ overflow: hidden;
1359
+ transition: border-color 0.13s ease, background 0.13s ease, transform 0.06s ease;
1360
+ }
1361
+ .ticket-card:hover {
1362
+ border-color: #3d425f;
1363
+ background: #181a26;
1364
+ }
1365
+ .ticket-card:active {
1366
+ transform: scale(0.99);
1367
+ }
1368
+ .ticket-card-bar {
1369
+ width: 3px;
1370
+ border-radius: 3px;
1371
+ flex-shrink: 0;
1372
+ align-self: stretch;
1373
+ }
1374
+ .ticket-card-main {
1375
+ flex: 1;
1376
+ min-width: 0;
1377
+ display: flex;
1378
+ flex-direction: column;
1379
+ gap: 5px;
1380
+ }
1381
+ .ticket-card-title {
1382
+ font-size: 13px;
1383
+ font-weight: 550;
1384
+ color: #d5dbf5;
1385
+ white-space: nowrap;
1386
+ overflow: hidden;
1387
+ text-overflow: ellipsis;
1388
+ }
1389
+ .ticket-card-meta {
1390
+ display: flex;
1391
+ align-items: center;
1392
+ gap: 10px;
1393
+ }
1394
+ .ticket-card-status {
1395
+ display: inline-flex;
1396
+ align-items: center;
1397
+ gap: 5px;
1398
+ font-size: 11px;
1399
+ font-weight: 600;
1400
+ }
1401
+ .ticket-card-date {
1402
+ font-size: 11px;
1403
+ color: #565f89;
1404
+ }
1405
+ .ticket-card-pri {
1406
+ align-self: center;
1407
+ font-size: 10px;
1408
+ font-weight: 700;
1409
+ letter-spacing: 0.04em;
1410
+ text-transform: uppercase;
1411
+ border: 1px solid;
1412
+ border-radius: 999px;
1413
+ padding: 2px 8px;
1414
+ flex-shrink: 0;
1415
+ }