@phenx-inc/ctlsurf 0.10.0 → 0.11.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 (27) hide show
  1. package/out/headless/index.mjs +1 -1
  2. package/out/headless/index.mjs.map +1 -1
  3. package/out/main/index.js +46 -21
  4. package/out/renderer/assets/{cssMode-BbCVOTaJ.js → cssMode-Bmzy9hKX.js} +3 -3
  5. package/out/renderer/assets/{freemarker2-DryLywQv.js → freemarker2-CDAsExTa.js} +1 -1
  6. package/out/renderer/assets/{handlebars-CdUMtSdr.js → handlebars-CvpBYG1L.js} +1 -1
  7. package/out/renderer/assets/{html-Cwkf2GMH.js → html-CNhEQqr8.js} +1 -1
  8. package/out/renderer/assets/{htmlMode-C7PzhXxH.js → htmlMode-DD7YPgga.js} +3 -3
  9. package/out/renderer/assets/{index-Dg1vMspf.js → index-BDlCPzCG.js} +144 -144
  10. package/out/renderer/assets/{index-BIceGcz3.css → index-CGHeDwZa.css} +5 -46
  11. package/out/renderer/assets/{javascript-Cy7ROXJl.js → javascript-CsGLsswK.js} +2 -2
  12. package/out/renderer/assets/{jsonMode-BtZtt3QW.js → jsonMode-CTuGgRvC.js} +3 -3
  13. package/out/renderer/assets/{liquid-xReAQeYs.js → liquid-BrrN4uul.js} +1 -1
  14. package/out/renderer/assets/{lspLanguageFeatures-Dmx0L0Bw.js → lspLanguageFeatures-Bhdn2Eqy.js} +1 -1
  15. package/out/renderer/assets/{mdx-YMNFO1Zz.js → mdx-DJo-gax4.js} +1 -1
  16. package/out/renderer/assets/{python-BExaDVxv.js → python-DYHeg5b2.js} +1 -1
  17. package/out/renderer/assets/{razor-HL8ANmQ4.js → razor-Cqcks14K.js} +1 -1
  18. package/out/renderer/assets/{tsMode-Dqd8vdLE.js → tsMode-YTLgCayh.js} +1 -1
  19. package/out/renderer/assets/{typescript-Dysz1PTX.js → typescript-oxBn_Vcw.js} +1 -1
  20. package/out/renderer/assets/{xml-BHp5Z9nc.js → xml-BEqGMCTc.js} +1 -1
  21. package/out/renderer/assets/{yaml-CPDHxVup.js → yaml-BGSrk5HC.js} +1 -1
  22. package/out/renderer/index.html +2 -2
  23. package/package.json +1 -1
  24. package/src/main/index.ts +60 -31
  25. package/src/renderer/App.tsx +9 -7
  26. package/src/renderer/components/TicketPanel.tsx +20 -25
  27. package/src/renderer/styles.css +5 -46
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#",
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const EMPTY_ELEMENTS = [
3
3
  "area",
4
4
  "base",
@@ -1,4 +1,4 @@
1
- import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-Dg1vMspf.js";
1
+ import { c as createWebWorker, e as editor, U as Uri, a as MarkerTag, M as MarkerSeverity, l as languages, t as typescriptDefaults, R as Range } from "./index-BDlCPzCG.js";
2
2
  class WorkerManager {
3
3
  constructor(_modeId, _defaults) {
4
4
  this._modeId = _modeId;
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
4
4
  comments: {
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  blockComment: ["<!--", "-->"]
@@ -1,4 +1,4 @@
1
- import { l as languages } from "./index-Dg1vMspf.js";
1
+ import { l as languages } from "./index-BDlCPzCG.js";
2
2
  const conf = {
3
3
  comments: {
4
4
  lineComment: "#"
@@ -49,8 +49,8 @@
49
49
  color: #565f89;
50
50
  }
51
51
  </style>
52
- <script type="module" crossorigin src="./assets/index-Dg1vMspf.js"></script>
53
- <link rel="stylesheet" crossorigin href="./assets/index-BIceGcz3.css">
52
+ <script type="module" crossorigin src="./assets/index-BDlCPzCG.js"></script>
53
+ <link rel="stylesheet" crossorigin href="./assets/index-CGHeDwZa.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="boot-splash">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phenx-inc/ctlsurf",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Agent-agnostic terminal and desktop app for ctlsurf — run Claude Code, Codex, or any coding agent with live session logging and remote control",
5
5
  "main": "out/main/index.js",
6
6
  "bin": {
package/src/main/index.ts CHANGED
@@ -51,12 +51,12 @@ function getProjectAbbrev(cwdPath: string): string {
51
51
  return word.slice(0, 2).toUpperCase()
52
52
  }
53
53
 
54
- // Draw a 32x32 filled circle overlay. `red` switches between the project-badge
55
- // blue and the "awaiting input" red dot.
56
- function createDotOverlay(red: boolean): Electron.NativeImage {
54
+ // Draw a 32x32 filled circle overlay (Windows taskbar). `ready` switches between
55
+ // the project-badge blue and the green "ready for you" dot.
56
+ function createDotOverlay(ready: boolean): Electron.NativeImage {
57
57
  const size = 32
58
58
  const canvas = Buffer.alloc(size * size * 4, 0) // RGBA
59
- const [r, g, b] = red ? [235, 64, 52] : [122, 162, 247]
59
+ const [r, g, b] = ready ? [52, 199, 89] : [122, 162, 247]
60
60
  for (let y = 0; y < size; y++) {
61
61
  for (let x = 0; x < size; x++) {
62
62
  const cx = x - size / 2
@@ -73,6 +73,43 @@ function createDotOverlay(red: boolean): Electron.NativeImage {
73
73
  return nativeImage.createFromBuffer(canvas, { width: size, height: size })
74
74
  }
75
75
 
76
+ // The plain dock icon (macOS), loaded once at startup. Reused to composite the
77
+ // green "ready" dot on top, and to restore the clean icon when it clears.
78
+ let baseDockIcon: Electron.NativeImage | null = null
79
+
80
+ // Composite a green "ready for you" dot (with a soft dark ring for contrast) into
81
+ // the lower-right of the dock icon. Reads/writes the bitmap as a matched
82
+ // toBitmap()/createFromBitmap() pair, which share the same (BGRA) byte order.
83
+ function dockIconWithReadyDot(base: Electron.NativeImage): Electron.NativeImage {
84
+ const { width, height } = base.getSize()
85
+ if (!width || !height) return base
86
+ const bmp = base.toBitmap()
87
+ const r = Math.round(Math.min(width, height) * 0.17) // dot radius
88
+ const ring = Math.max(2, Math.round(r * 0.22)) // ring thickness
89
+ const cx = width - r - Math.round(width * 0.05)
90
+ const cy = height - r - Math.round(height * 0.05)
91
+ const put = (x: number, y: number, b: number, g: number, rr: number) => {
92
+ const idx = (y * width + x) * 4
93
+ bmp[idx] = b; bmp[idx + 1] = g; bmp[idx + 2] = rr; bmp[idx + 3] = 255
94
+ }
95
+ for (let y = cy - r - ring; y <= cy + r + ring; y++) {
96
+ if (y < 0 || y >= height) continue
97
+ for (let x = cx - r - ring; x <= cx + r + ring; x++) {
98
+ if (x < 0 || x >= width) continue
99
+ const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2)
100
+ if (d <= r) put(x, y, 89, 199, 52) // green #34c759 (BGRA)
101
+ else if (d <= r + ring) put(x, y, 24, 26, 31) // dark ring #1f1a18
102
+ }
103
+ }
104
+ return nativeImage.createFromBitmap(bmp, { width, height })
105
+ }
106
+
107
+ // Show/clear the green "ready" dot on the macOS dock icon.
108
+ function setDockReadyDot(on: boolean): void {
109
+ if (process.platform !== 'darwin' || !app.dock || !baseDockIcon) return
110
+ app.dock.setIcon(on ? dockIconWithReadyDot(baseDockIcon) : baseDockIcon)
111
+ }
112
+
76
113
  // Last project abbreviation, so the steady badge can be restored after the
77
114
  // "awaiting input" indicator clears.
78
115
  let projectAbbrev = ''
@@ -90,8 +127,9 @@ function updateProjectBadge(cwdPath: string | null): void {
90
127
  if (!cwdPath) return
91
128
  projectAbbrev = getProjectAbbrev(cwdPath)
92
129
 
93
- // Don't stomp the awaiting-input blink; it restores the steady badge itself.
94
- if (!blinkTimer) applySteadyBadge()
130
+ // The project badge and the "ready" dot are now independent surfaces (badge
131
+ // vs dock icon), so the badge can always update.
132
+ applySteadyBadge()
95
133
 
96
134
  // All platforms: update window title
97
135
  if (mainWindow) {
@@ -100,18 +138,17 @@ function updateProjectBadge(cwdPath: string | null): void {
100
138
  }
101
139
  }
102
140
 
103
- // ─── "Awaiting input" attention indicator (cross-platform) ──────────
141
+ // ─── "Ready for you" attention indicator (cross-platform) ──────────
104
142
  //
105
143
  // The detection is OS-independent; only the visual differs:
106
- // macOS — blink a red dot in the dock badge + one bounce
107
- // Windows — red overlay icon on the taskbar button + flash the button
144
+ // macOS — steady green dot composited on the dock icon + one bounce
145
+ // Windows — green overlay icon on the taskbar button + flash the button
108
146
  // Linux — badge count where supported + flash (window urgency hint)
109
147
  // While the window is focused the dock/taskbar nag is suppressed (the in-app
110
148
  // dot is enough); the renderer always gets `agent:state` regardless of OS.
111
149
 
112
150
  const attention = new AttentionController()
113
- let blinkTimer: ReturnType<typeof setInterval> | null = null
114
- let blinkOn = false
151
+ let attentionActive = false
115
152
  let bounceId = -1
116
153
 
117
154
  function setAwaitingIndicator(on: boolean): void {
@@ -124,40 +161,31 @@ function applyAttention(active: boolean): void {
124
161
  }
125
162
 
126
163
  function startAttention(): void {
127
- if (blinkTimer) return
128
- log('[agent-state] attention START (blink + bounce)')
164
+ if (attentionActive) return
165
+ attentionActive = true
166
+ log('[agent-state] attention START (green ready dot + bounce)')
129
167
 
130
168
  if (process.platform === 'darwin' && app.dock) {
169
+ setDockReadyDot(true)
131
170
  bounceId = app.dock.bounce('informational')
132
171
  }
133
172
  if (process.platform === 'win32' && mainWindow) {
134
- mainWindow.setOverlayIcon(createDotOverlay(true), 'Awaiting input')
173
+ mainWindow.setOverlayIcon(createDotOverlay(true), 'Ready for you')
135
174
  mainWindow.flashFrame(true)
136
175
  }
137
176
  if (process.platform === 'linux') {
138
177
  if (typeof app.badgeCount === 'number') app.badgeCount = 1
139
178
  mainWindow?.flashFrame(true)
140
179
  }
141
-
142
- // Blink the macOS dock badge — the whole red pill (project initials and all)
143
- // toggles on/off, so the project letters are preserved on the "on" frames
144
- // rather than being replaced by a dot.
145
- blinkOn = true
146
- blinkTimer = setInterval(() => {
147
- blinkOn = !blinkOn
148
- if (process.platform === 'darwin' && app.dock) {
149
- app.dock.setBadge(blinkOn ? (projectAbbrev || '●') : '')
150
- }
151
- }, 600)
152
180
  }
153
181
 
154
182
  function stopAttention(): void {
155
- if (blinkTimer) {
156
- clearInterval(blinkTimer)
157
- blinkTimer = null
158
- log('[agent-state] attention STOP')
159
- }
183
+ if (!attentionActive) return
184
+ attentionActive = false
185
+ log('[agent-state] attention STOP')
186
+
160
187
  if (process.platform === 'darwin' && app.dock) {
188
+ setDockReadyDot(false)
161
189
  if (bounceId >= 0) { app.dock.cancelBounce(bounceId); bounceId = -1 }
162
190
  }
163
191
  if (process.platform === 'win32' && mainWindow) {
@@ -582,7 +610,8 @@ app.whenReady().then(() => {
582
610
  if (process.platform === 'darwin' && app.dock) {
583
611
  const iconPath = path.join(__dirname, '../../resources/icon.png')
584
612
  try {
585
- app.dock.setIcon(nativeImage.createFromPath(iconPath))
613
+ baseDockIcon = nativeImage.createFromPath(iconPath)
614
+ app.dock.setIcon(baseDockIcon)
586
615
  } catch { /* ignore */ }
587
616
  }
588
617
 
@@ -14,7 +14,7 @@ import {
14
14
  } from './components/PaneLayout'
15
15
  import { StatusBar } from './components/StatusBar'
16
16
  import { SettingsDialog } from './components/SettingsDialog'
17
- import { TicketPanel } from './components/TicketPanel'
17
+ import { TicketPane } from './components/TicketPanel'
18
18
 
19
19
  interface AgentConfig {
20
20
  id: string
@@ -96,11 +96,13 @@ const DEFAULT_LAYOUT: LayoutNode = {
96
96
  direction: 'horizontal',
97
97
  children: [
98
98
  { type: 'leaf', paneId: 'terminal' },
99
- { type: 'leaf', paneId: 'ctlsurf' },
99
+ { type: 'leaf', paneId: 'tickets' },
100
100
  ],
101
101
  sizes: [50, 50],
102
102
  }
103
103
 
104
+ // Panes shown as plain toggle buttons in the titlebar. `tickets` has its own
105
+ // dedicated icon button, so it's not listed here.
104
106
  const ALL_PANE_IDS = ['editor', 'terminal', 'ctlsurf']
105
107
 
106
108
  interface TabInfo {
@@ -141,7 +143,6 @@ export default function App() {
141
143
  // Per-tab agent activity ('busy' | 'awaiting_input' | 'awaiting_decision' | 'idle'),
142
144
  // driven by the main process. Powers the awaiting-input dot on each tab.
143
145
  const [agentStates, setAgentStates] = useState<Record<string, string>>({})
144
- const [showTicketPanel, setShowTicketPanel] = useState(false)
145
146
  // Draggable on-canvas push-to-talk mic; visibility persists across launches.
146
147
  const [showFloatingMic, setShowFloatingMic] = useState<boolean>(() => {
147
148
  try { return localStorage.getItem('ctlsurf.floatingMicVisible') !== 'false' } catch { return true }
@@ -399,6 +400,7 @@ export default function App() {
399
400
  case 'E': e.preventDefault(); togglePane('editor'); break
400
401
  case 'T': e.preventDefault(); togglePane('terminal'); break
401
402
  case 'P': e.preventDefault(); togglePane('ctlsurf'); break
403
+ case 'K': e.preventDefault(); togglePane('tickets'); break
402
404
  }
403
405
  }
404
406
  }
@@ -488,6 +490,7 @@ export default function App() {
488
490
  ),
489
491
  },
490
492
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
493
+ { id: 'tickets', label: 'Tickets', content: <TicketPane /> },
491
494
  ]
492
495
 
493
496
  return (
@@ -516,9 +519,9 @@ export default function App() {
516
519
  <span className={`tracking-dot ${trackingActive ? 'on' : 'off'}`} />
517
520
  </button>
518
521
  <button
519
- className={`titlebar-btn titlebar-icon-btn ${showTicketPanel ? 'active' : ''}`}
520
- onClick={() => setShowTicketPanel(v => !v)}
521
- title="Tickets for the active project"
522
+ className={`titlebar-btn titlebar-icon-btn ${visiblePaneIds.includes('tickets') ? 'active' : ''}`}
523
+ onClick={() => togglePane('tickets')}
524
+ title="Toggle tickets pane for the active project"
522
525
  aria-label="Tickets"
523
526
  >
524
527
  <svg className="ticket-tag-icon" viewBox="0 0 24 24" width="13" height="13"
@@ -589,7 +592,6 @@ export default function App() {
589
592
 
590
593
  <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
591
594
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
592
- <TicketPanel open={showTicketPanel} onClose={() => setShowTicketPanel(false)} />
593
595
 
594
596
  {showAgentPicker && agents.length > 0 && (
595
597
  <AgentPicker
@@ -26,11 +26,6 @@ interface Ticket {
26
26
  created: string | null
27
27
  }
28
28
 
29
- interface TicketPanelProps {
30
- open: boolean
31
- onClose: () => void
32
- }
33
-
34
29
  type SaveState = { kind: 'idle' } | { kind: 'saving' } | { kind: 'error'; message: string }
35
30
  type View = 'list' | 'form'
36
31
 
@@ -79,7 +74,10 @@ function Segmented({ options, value, onChange }: SegmentedProps) {
79
74
  )
80
75
  }
81
76
 
82
- export function TicketPanel({ open, onClose }: TicketPanelProps) {
77
+ // Docked side-pane variant of the ticket UI: header + list/form body + footer.
78
+ // It only mounts when its pane is visible, so it loads on mount and refreshes
79
+ // when the connected project (cwd) changes. Closed via the pane's own header.
80
+ export function TicketPane() {
83
81
  const [project, setProject] = useState<string | null>(null)
84
82
  const [view, setView] = useState<View>('list')
85
83
 
@@ -111,17 +109,18 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
111
109
  }
112
110
  }, [])
113
111
 
114
- // On open: reset to the list and (re)load project + tickets.
112
+ // Load project + tickets on mount, and refresh whenever the connected project
113
+ // (cwd) changes so the list always tracks the active project.
115
114
  useEffect(() => {
116
- if (!open) return
117
- setView('list')
118
115
  let cancelled = false
119
- window.worker.getTicketProject()
116
+ const loadProject = () => window.worker.getTicketProject()
120
117
  .then(r => { if (!cancelled) setProject(r?.name ?? null) })
121
118
  .catch(() => { if (!cancelled) setProject(null) })
119
+ loadProject()
122
120
  void refreshList()
123
- return () => { cancelled = true }
124
- }, [open, refreshList])
121
+ const unsub = window.worker.onCwdChanged(() => { loadProject(); void refreshList() })
122
+ return () => { cancelled = true; unsub() }
123
+ }, [refreshList])
125
124
 
126
125
  const openNewForm = useCallback(() => {
127
126
  setEditingId(null)
@@ -162,28 +161,26 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
162
161
  }
163
162
  }, [title, description, status, priority, editingId, save.kind, refreshList])
164
163
 
165
- // Esc: form → back to list, list → close. Cmd/Ctrl+Enter saves in the form.
164
+ // Only capture keys while editing a ticket, so list-view typing in the terminal
165
+ // isn't hijacked. Esc returns to the list; Cmd/Ctrl+Enter saves.
166
166
  useEffect(() => {
167
- if (!open) return
167
+ if (view !== 'form') return
168
168
  const onKey = (e: KeyboardEvent) => {
169
169
  if (e.key === 'Escape') {
170
170
  e.preventDefault()
171
- if (view === 'form') setView('list')
172
- else onClose()
173
- } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && view === 'form') {
171
+ setView('list')
172
+ } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
174
173
  e.preventDefault()
175
174
  void handleSave()
176
175
  }
177
176
  }
178
177
  window.addEventListener('keydown', onKey)
179
178
  return () => window.removeEventListener('keydown', onKey)
180
- }, [open, view, onClose, handleSave])
179
+ }, [view, handleSave])
181
180
 
182
181
  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">
182
+ <div className="ticket-pane" role="region" aria-label="Tickets">
183
+ <div className="ticket-drawer-header">
187
184
  <div className="ticket-drawer-heading">
188
185
  {view === 'form' ? (
189
186
  <button className="ticket-drawer-back" onClick={() => setView('list')} title="Back (Esc)">‹</button>
@@ -201,7 +198,6 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
201
198
  </span>
202
199
  </div>
203
200
  </div>
204
- <button className="ticket-drawer-close" onClick={onClose} title="Close">×</button>
205
201
  </div>
206
202
 
207
203
  {view === 'list' ? (
@@ -302,7 +298,6 @@ export function TicketPanel({ open, onClose }: TicketPanelProps) {
302
298
  </div>
303
299
  </>
304
300
  )}
305
- </div>
306
- </>
301
+ </div>
307
302
  )
308
303
  }
@@ -1458,38 +1458,13 @@ html, body, #root {
1458
1458
 
1459
1459
  /* ─── Ticket drawer ──────────────────────────────── */
1460
1460
 
1461
- .ticket-scrim {
1462
- position: fixed;
1463
- inset: 0;
1464
- background: rgba(13, 14, 22, 0.55);
1465
- backdrop-filter: blur(2px);
1466
- opacity: 0;
1467
- pointer-events: none;
1468
- transition: opacity 0.2s ease;
1469
- z-index: 90;
1470
- }
1471
- .ticket-scrim.open {
1472
- opacity: 1;
1473
- pointer-events: auto;
1474
- }
1475
-
1476
- .ticket-drawer {
1477
- position: fixed;
1478
- top: 0;
1479
- right: 0;
1480
- bottom: 0;
1481
- width: 380px;
1482
- background: linear-gradient(180deg, #20243a 0%, #1b1e2e 100%);
1483
- border-left: 1px solid #343860;
1484
- box-shadow: -16px 0 48px rgba(0, 0, 0, 0.5);
1461
+ /* Docked side-pane variant: fills its pane (header/body/footer flex column). */
1462
+ .ticket-pane {
1485
1463
  display: flex;
1486
1464
  flex-direction: column;
1487
- transform: translateX(100%);
1488
- transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1);
1489
- z-index: 100;
1490
- }
1491
- .ticket-drawer.open {
1492
- transform: translateX(0);
1465
+ height: 100%;
1466
+ min-height: 0;
1467
+ background: linear-gradient(180deg, #20243a 0%, #1b1e2e 100%);
1493
1468
  }
1494
1469
 
1495
1470
  /* ── header ── */
@@ -1539,22 +1514,6 @@ html, body, #root {
1539
1514
  .ticket-drawer-warn {
1540
1515
  color: #e0af68;
1541
1516
  }
1542
- .ticket-drawer-close {
1543
- background: none;
1544
- border: none;
1545
- color: #565f89;
1546
- font-size: 20px;
1547
- line-height: 1;
1548
- cursor: pointer;
1549
- padding: 2px 6px;
1550
- border-radius: 6px;
1551
- transition: background 0.12s ease, color 0.12s ease;
1552
- }
1553
- .ticket-drawer-close:hover {
1554
- color: #d5dbf5;
1555
- background: rgba(255, 255, 255, 0.06);
1556
- }
1557
-
1558
1517
  /* ── body ── */
1559
1518
  .ticket-drawer-body {
1560
1519
  flex: 1;