@idoall/dsh-session-colors 0.1.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.
package/src/client.js ADDED
@@ -0,0 +1,1109 @@
1
+ /**
2
+ * Browser half of `@idoall/dsh-session-colors`: per-Session colour marks.
3
+ *
4
+ * Two surfaces, both reached through public slots, so no shipped file is
5
+ * touched and nothing is DOM-patched:
6
+ *
7
+ * - `conversation.session.header.actions` carries the picker button and its
8
+ * system-style panel (saturation/value field, hue, alpha, hex, RGBA, theme
9
+ * swatches, saved colours).
10
+ * - `shell.overlay` carries the chip layer: a frame-wide, fixed, click-through
11
+ * surface on which one small colour chip is painted per marked Session row.
12
+ * The surface is registered there but rendered through a portal on
13
+ * `document.body`, because DSH confines the overlay to a stacking context of
14
+ * its own and a phone adapter may promote the sidebar above it — see
15
+ * `ChipsLayer` and `layerZIndexOf`.
16
+ *
17
+ * There is no Session-row slot, so a chip cannot be rendered inside a row. The
18
+ * layer finds rows by ARIA role and reads each row's Session id from the React
19
+ * fiber the renderer already attached — the only place the id exists, since the
20
+ * row markup carries no id attribute. That coupling is deliberate and is kept
21
+ * harmless: every step is guarded, an unreadable row is skipped, and the layer
22
+ * never accepts pointer events, so a future DSH change can at worst stop the
23
+ * chips from appearing.
24
+ *
25
+ * Written as a hand-authored `__ModuleLoader__` bundle (the only format the
26
+ * client loader accepts for a package client half), so it needs no build step.
27
+ */
28
+ window.__ModuleLoader__.load({
29
+ id: '@idoall/dsh-session-colors',
30
+ factory: (require) => {
31
+ const module = { exports: {} }
32
+ const exports = module.exports
33
+ const React = require('react')
34
+ /**
35
+ * `react-dom` is one of the DSH web shell's static modules. It is read
36
+ * defensively: a shell that stopped exposing it would cost the chip layer
37
+ * its escape from the shell overlay's stacking context (see `ChipsLayer`),
38
+ * not the whole plugin.
39
+ */
40
+ let createPortal
41
+ try {
42
+ createPortal = require('react-dom').createPortal
43
+ } catch {
44
+ createPortal = undefined
45
+ }
46
+
47
+ const NS = 'sessionColor'
48
+ /**
49
+ * Build marker. Stored marks are Host-side as of this build, and the chip
50
+ * layer paints just above the sidebar as of the `drawer-aware` build — the
51
+ * change that makes a chip visible inside a phone's sidebar drawer. A device
52
+ * that still shows an older marker is running a cached client and must be
53
+ * hard-refreshed.
54
+ */
55
+ const BUILD = 'host-routes+drawer-aware'
56
+
57
+ /**
58
+ * This plugin's own Host route. Kept in sync with the Host half's
59
+ * ROUTE_PREFIX; it is the durable store because DSH only host-backs the
60
+ * `settings` service for loopback pages.
61
+ */
62
+ const MARKS_ROUTE = '/plugins/dsh-session-colors/marks'
63
+
64
+ /** How often an open page re-reads marks another device may have changed. */
65
+ const PEER_POLL_MS = 20000
66
+ const ACTION_ID = 'session-color-mark'
67
+ const OVERLAY_ID = 'session-color-chips'
68
+ const STYLE_ID = 'dsh-session-color-style'
69
+
70
+ /** Chip geometry: a bar in the row's 8px left padding, clear of the status dot. */
71
+ const CHIP_WIDTH = 4
72
+ const CHIP_HEIGHT = 16
73
+ const CHIP_OFFSET = 2
74
+
75
+ /**
76
+ * How long to keep re-measuring after an ancestor of the rows starts moving.
77
+ * The phone adapter slides its sidebar in over ~250ms; 40 frames covers that
78
+ * and then stops on its own, so an idle page never runs a measuring loop.
79
+ */
80
+ const SETTLE_FRAMES = 40
81
+
82
+ /** Theme swatches: the shipped macOS picker set, last one fully transparent. */
83
+ const THEME = [
84
+ ['#FF3B30', 1], ['#34C759', 1], ['#007AFF', 1], ['#FFCC00', 1],
85
+ ['#FFFFFF', 1], ['#000000', 1], ['#FFFFFF', 0],
86
+ ]
87
+
88
+ const zh = {
89
+ mark: '颜色标记',
90
+ clear: '清除标记',
91
+ theme: '主题颜色',
92
+ mine: '我的颜色',
93
+ hex: '十六进制',
94
+ alpha: '透明度',
95
+ saturation: '饱和度与明度',
96
+ hue: '色相',
97
+ eyedrop: '吸取屏幕颜色',
98
+ addMine: '把当前颜色加入我的颜色',
99
+ custom: '自定义颜色',
100
+ layer: '会话颜色标记',
101
+ panelTitle: '选择会话颜色',
102
+ none: '当前会话没有颜色标记',
103
+ current: (hex, alpha) => `当前会话颜色:${hex} · A ${alpha}%`,
104
+ saved: '颜色只保存会话 ID 与颜色值:不复制、不移动会话,也不改所属工作区与排序。',
105
+ hint: '点「颜色标记」打开选择器。',
106
+ offline: '颜色标记暂时读不到宿主设置:这台设备可能还在用旧的客户端缓存,请强制刷新(Cmd+Shift+R)。',
107
+ chip: '已标记颜色',
108
+ }
109
+ const en = {
110
+ mark: 'Colour mark',
111
+ clear: 'Clear mark',
112
+ theme: 'Theme colours',
113
+ mine: 'My colours',
114
+ hex: 'Hex',
115
+ alpha: 'Alpha',
116
+ saturation: 'Saturation and brightness',
117
+ hue: 'Hue',
118
+ eyedrop: 'Pick a colour from the screen',
119
+ addMine: 'Save the current colour',
120
+ custom: 'Custom colour',
121
+ layer: 'Session colour marks',
122
+ panelTitle: 'Choose a Session colour',
123
+ none: 'This Session has no colour mark',
124
+ current: (hex, alpha) => `Session colour: ${hex} · A ${alpha}%`,
125
+ saved: 'A mark stores only the Session id and colour value: it never copies, moves, or reorders a Session.',
126
+ hint: 'Open the picker with “Colour mark”.',
127
+ offline: 'Colour marks cannot reach the Host settings yet: this device may still run a cached client. Hard-refresh (Cmd+Shift+R).',
128
+ chip: 'Colour marked',
129
+ }
130
+
131
+ //#region colour maths
132
+ function clamp(value, min, max) {
133
+ return Math.min(max, Math.max(min, value))
134
+ }
135
+
136
+ function hsvToRgb(h, s, v) {
137
+ const c = v * s
138
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
139
+ const m = v - c
140
+ let r = 0
141
+ let g = 0
142
+ let b = 0
143
+ if (h < 60) { r = c; g = x } else if (h < 120) { r = x; g = c } else if (h < 180) { g = c; b = x } else if (h < 240) { g = x; b = c } else if (h < 300) { r = x; b = c } else { r = c; b = x }
144
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]
145
+ }
146
+
147
+ function rgbToHsv(r, g, b) {
148
+ const red = r / 255
149
+ const green = g / 255
150
+ const blue = b / 255
151
+ const max = Math.max(red, green, blue)
152
+ const min = Math.min(red, green, blue)
153
+ const delta = max - min
154
+ let h = 0
155
+ if (delta !== 0) {
156
+ if (max === red) h = 60 * (((green - blue) / delta) % 6)
157
+ else if (max === green) h = 60 * ((blue - red) / delta + 2)
158
+ else h = 60 * ((red - green) / delta + 4)
159
+ }
160
+ if (h < 0) h += 360
161
+ return { h, s: max === 0 ? 0 : delta / max, v: max }
162
+ }
163
+
164
+ function byteToHex(value) {
165
+ return value.toString(16).padStart(2, '0').toUpperCase()
166
+ }
167
+
168
+ function toHex(color) {
169
+ const [r, g, b] = hsvToRgb(color.h, color.s, color.v)
170
+ return `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}`
171
+ }
172
+
173
+ function parseHex(raw) {
174
+ const value = String(raw).trim().replace(/^#/, '').toUpperCase()
175
+ if (!/^[0-9A-F]{3}$|^[0-9A-F]{6}$|^[0-9A-F]{8}$/.test(value)) return undefined
176
+ const full = value.length === 3 ? value.split('').map((ch) => ch + ch).join('') : value.slice(0, 6)
177
+ const r = parseInt(full.slice(0, 2), 16)
178
+ const g = parseInt(full.slice(2, 4), 16)
179
+ const b = parseInt(full.slice(4, 6), 16)
180
+ const a = value.length === 8 ? parseInt(value.slice(6, 8), 16) / 255 : 1
181
+ return { ...rgbToHsv(r, g, b), a }
182
+ }
183
+
184
+ /** CSS colour for a stored mark, including its alpha. */
185
+ function cssOf(color) {
186
+ if (color === undefined) return undefined
187
+ const [r, g, b] = hsvToRgb(color.h, color.s, color.v)
188
+ return `rgba(${r}, ${g}, ${b}, ${color.a})`
189
+ }
190
+ //#endregion
191
+
192
+ //#region preference store
193
+ /** One HSVA colour, as the Host route accepts it. */
194
+ function isColor(value) {
195
+ return value !== null
196
+ && typeof value === 'object'
197
+ && typeof value.h === 'number'
198
+ && typeof value.s === 'number'
199
+ && typeof value.v === 'number'
200
+ && typeof value.a === 'number'
201
+ }
202
+
203
+ /** Keep only well-formed entries so one bad record cannot break the layer. */
204
+ function readMarks(marks) {
205
+ const colors = {}
206
+ if (marks === null || typeof marks !== 'object') return colors
207
+ for (const id of Object.keys(marks)) {
208
+ if (isColor(marks[id])) colors[id] = marks[id]
209
+ }
210
+ return colors
211
+ }
212
+
213
+ /**
214
+ * Marks store backed by this plugin's own Host route.
215
+ *
216
+ * Not `settingsScope`: DSH host-backs user settings only for loopback pages
217
+ * (`persistence = $host.isLoopback ? "host" : "memory"`), so a page opened
218
+ * over the LAN or a tunnel gets a permanently `unavailable` scope. An own
219
+ * route is not subject to that rule, which is what makes a mark set on one
220
+ * device visible on another.
221
+ *
222
+ * Writes are optimistic — the UI updates at once — and the Host's answer
223
+ * replaces the view, so a failed write is visible rather than silent.
224
+ */
225
+ function createStore() {
226
+ let snapshot = { colors: {}, ready: false, status: 'loading', writable: true, error: '' }
227
+ const listeners = new Set()
228
+
229
+ const publish = () => {
230
+ for (const listener of [...listeners]) {
231
+ try {
232
+ listener()
233
+ } catch {
234
+ // A failing consumer must not stop the others.
235
+ }
236
+ }
237
+ }
238
+
239
+ const describe = () => ({
240
+ status: snapshot.status,
241
+ writable: snapshot.writable,
242
+ marks: Object.keys(snapshot.colors).length,
243
+ error: snapshot.error,
244
+ })
245
+
246
+ /** Adopt whatever the Host serves. */
247
+ const load = () => {
248
+ let request
249
+ try {
250
+ request = fetch(MARKS_ROUTE, { credentials: 'same-origin', headers: { accept: 'application/json' } })
251
+ } catch (error) {
252
+ snapshot = { ...snapshot, ready: false, status: 'unreachable', error: String(error) }
253
+ publish()
254
+ return
255
+ }
256
+ // `.then(onOk, onErr)` would leave the async handler's own throw
257
+ // unhandled; a trailing catch covers both the HTTP failure and the
258
+ // body parse.
259
+ request
260
+ .then(async (response) => {
261
+ if (!response.ok) throw new Error(`marks route answered ${response.status}`)
262
+ const payload = await response.json()
263
+ snapshot = {
264
+ colors: readMarks(payload === null || payload === undefined ? undefined : payload.marks),
265
+ ready: true,
266
+ status: 'ready',
267
+ writable: true,
268
+ error: '',
269
+ }
270
+ publish()
271
+ })
272
+ .catch((error) => {
273
+ snapshot = { ...snapshot, ready: false, status: 'unreachable', error: String(error) }
274
+ publish()
275
+ })
276
+ }
277
+
278
+ const write = (sessionId, color) => {
279
+ const colors = { ...snapshot.colors }
280
+ if (color === undefined) delete colors[sessionId]
281
+ else colors[sessionId] = color
282
+ snapshot = { ...snapshot, colors }
283
+ publish()
284
+ let request
285
+ try {
286
+ request = fetch(MARKS_ROUTE, {
287
+ method: 'POST',
288
+ credentials: 'same-origin',
289
+ headers: { 'content-type': 'application/json' },
290
+ body: JSON.stringify({ sessionId, color: color === undefined ? null : color }),
291
+ })
292
+ } catch (error) {
293
+ snapshot = { ...snapshot, error: String(error), status: 'write-failed' }
294
+ publish()
295
+ return
296
+ }
297
+ request
298
+ .then(async (response) => {
299
+ if (!response.ok) throw new Error(`marks route answered ${response.status}`)
300
+ const payload = await response.json()
301
+ snapshot = {
302
+ colors: readMarks(payload === null || payload === undefined ? undefined : payload.marks),
303
+ ready: true,
304
+ status: 'ready',
305
+ writable: true,
306
+ error: '',
307
+ }
308
+ publish()
309
+ })
310
+ .catch((error) => {
311
+ // Keep the optimistic view but say it is unsaved, then reconcile.
312
+ snapshot = { ...snapshot, status: 'write-failed', error: String(error) }
313
+ publish()
314
+ load()
315
+ })
316
+ }
317
+
318
+ // Another device may have changed a mark: re-read when the page becomes
319
+ // visible again and on a slow interval while it stays open.
320
+ const onVisible = () => {
321
+ try {
322
+ if (document.visibilityState === 'visible') load()
323
+ } catch {
324
+ // No document visibility: the interval still covers it.
325
+ }
326
+ }
327
+ try {
328
+ document.addEventListener('visibilitychange', onVisible)
329
+ } catch {
330
+ // Nothing to attach.
331
+ }
332
+ const timer = setInterval(load, PEER_POLL_MS)
333
+ // Under a test runner this is a Node Timeout, and a live interval would
334
+ // hold the process open after the suite finishes. Browsers return a
335
+ // number, where unref does not exist and is not needed.
336
+ if (timer !== null && typeof timer === 'object' && typeof timer.unref === 'function') timer.unref()
337
+
338
+ load()
339
+
340
+ return {
341
+ describe,
342
+ subscribe: (listener) => {
343
+ listeners.add(listener)
344
+ return () => { listeners.delete(listener) }
345
+ },
346
+ getSnapshot: () => snapshot,
347
+ set: (sessionId, color) => { write(sessionId, color) },
348
+ clear: (sessionId) => { write(sessionId, undefined) },
349
+ refresh: load,
350
+ dispose: () => {
351
+ clearInterval(timer)
352
+ try {
353
+ document.removeEventListener('visibilitychange', onVisible)
354
+ } catch {
355
+ // Nothing to detach.
356
+ }
357
+ listeners.clear()
358
+ },
359
+ }
360
+ }
361
+ //#endregion
362
+
363
+ //#region stylesheet
364
+ /**
365
+ * The picker keeps the system panel's own light palette rather than the
366
+ * app's dark chrome, so it reads as an attached system control. The chip
367
+ * layer itself is inert: fixed, full-frame and click-through.
368
+ *
369
+ * The layer's stacking level is measured per render (`layerZIndexOf`), not
370
+ * written here: the surface it decorates is the sidebar, and an adapter may
371
+ * promote that sidebar into a fixed drawer carrying a z-index of its own.
372
+ * The 1 below is only what the layer paints at before its first measurement.
373
+ */
374
+ const STYLE = `
375
+ .dsh-sc-layer{position:fixed;inset:0;z-index:1;overflow:hidden;pointer-events:none}
376
+ .dsh-sc-chip{position:absolute;width:${CHIP_WIDTH}px;height:${CHIP_HEIGHT}px;border-radius:2px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.28)}
377
+ .dsh-sc-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid rgba(128,128,128,.5);border-radius:7px;color:inherit;background:rgba(128,128,128,.12);cursor:pointer;font-size:13px;line-height:1}
378
+ .dsh-sc-action:hover{border-color:rgba(128,128,128,.85);background:rgba(128,128,128,.22)}
379
+ .dsh-sc-action:focus-visible{outline:2px solid #76a9ff;outline-offset:2px}
380
+ .dsh-sc-dot{width:13px;height:13px;border-radius:3px;box-shadow:inset 0 0 0 1px rgba(128,128,128,.6)}
381
+ .dsh-sc-panel{position:fixed;z-index:2147483001;box-sizing:border-box;width:min(268px,calc(100vw - 16px));max-height:calc(100vh - 16px);overflow:auto;padding:12px;border:1px solid #d8d8dc;border-radius:14px;background:#f6f6f8;color:#1d1d1f;box-shadow:0 18px 48px rgba(0,0,0,.34),0 0 0 1px rgba(0,0,0,.05);font:400 13px Inter,ui-sans-serif,system-ui,-apple-system,"PingFang SC",sans-serif}
382
+ .dsh-sc-panel *{box-sizing:border-box}
383
+ .dsh-sc-title{margin:0 0 10px;color:#1d1d1f;font-size:13px;font-weight:600}
384
+ .dsh-sc-sv{position:relative;height:128px;border-radius:10px;overflow:hidden;cursor:crosshair;touch-action:none}
385
+ .dsh-sc-sv-w,.dsh-sc-sv-b{position:absolute;inset:0;pointer-events:none}
386
+ .dsh-sc-sv-w{background:linear-gradient(to right,#fff,transparent)}
387
+ .dsh-sc-sv-b{background:linear-gradient(to bottom,transparent,#000)}
388
+ .dsh-sc-ring{position:absolute;width:16px;height:16px;margin:-8px 0 0 -8px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,.4),0 1px 3px rgba(0,0,0,.3);pointer-events:none}
389
+ .dsh-sc-row{display:flex;align-items:center;gap:10px;margin-top:12px}
390
+ .dsh-sc-drop{width:36px;height:36px;flex:none;display:grid;place-items:center;border:1px solid #d0d0d6;border-radius:10px;background:#fff;cursor:pointer;color:#3a3a3c;padding:0}
391
+ .dsh-sc-drop[disabled]{opacity:.45;cursor:not-allowed}
392
+ .dsh-sc-sliders{flex:1;display:grid;gap:8px}
393
+ .dsh-sc-hue,.dsh-sc-alpha{position:relative;height:12px;border-radius:999px;touch-action:none}
394
+ .dsh-sc-hue{background:linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)}
395
+ .dsh-sc-alpha{background-image:linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%),linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%);background-size:8px 8px;background-position:0 0,4px 4px}
396
+ .dsh-sc-alpha-fill{position:absolute;inset:0;border-radius:999px}
397
+ .dsh-sc-knob{position:absolute;top:50%;width:16px;height:16px;margin:-8px 0 0 -8px;border:2px solid #fff;border-radius:50%;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25),0 1px 2px rgba(0,0,0,.25);pointer-events:none}
398
+ .dsh-sc-fields{display:grid;grid-template-columns:1.55fr repeat(4,1fr);gap:4px;margin-top:12px}
399
+ .dsh-sc-field{display:grid;gap:3px;min-width:0}
400
+ .dsh-sc-field span{color:#6e6e73;font-size:11px}
401
+ .dsh-sc-field input{width:100%;height:28px;padding:0 2px;border:1px solid #d0d0d6;border-radius:7px;background:#fff;color:#1d1d1f;font-size:11px;text-align:center}
402
+ .dsh-sc-field input:focus-visible{outline:2px solid #76a9ff;outline-offset:1px}
403
+ .dsh-sc-section{margin-top:12px;padding-top:10px;border-top:1px solid #e5e5ea}
404
+ .dsh-sc-section h4{margin:0 0 8px;color:#6e6e73;font-size:12px;font-weight:600}
405
+ .dsh-sc-swatches{display:flex;flex-wrap:wrap;gap:7px}
406
+ .dsh-sc-swatch{width:22px;height:22px;border:1px solid rgba(0,0,0,.14);border-radius:6px;padding:0;cursor:pointer;background-image:linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%),linear-gradient(45deg,#c8c8cc 25%,transparent 25%,transparent 75%,#c8c8cc 75%);background-size:8px 8px;background-position:0 0,4px 4px}
407
+ .dsh-sc-swatch:focus-visible{outline:2px solid #76a9ff;outline-offset:2px}
408
+ .dsh-sc-swatch i{display:block;width:100%;height:100%;border-radius:5px}
409
+ .dsh-sc-add{width:22px;height:22px;border:1px dashed #b0b0b8;border-radius:6px;background:#fff;color:#3a3a3c;cursor:pointer;font-size:16px;line-height:1;padding:0}
410
+ .dsh-sc-actions{display:flex;justify-content:flex-end;gap:6px;margin-top:10px}
411
+ .dsh-sc-btn{height:26px;padding:0 10px;border:1px solid #d0d0d6;border-radius:7px;background:#fff;color:#1d1d1f;cursor:pointer;font-size:12px}
412
+ .dsh-sc-btn:focus-visible{outline:2px solid #76a9ff;outline-offset:2px}
413
+ `
414
+
415
+ /** Install the stylesheet once per document, and hand back its removal. */
416
+ function installStyle() {
417
+ try {
418
+ if (document.getElementById(STYLE_ID) !== null) return () => {}
419
+ const node = document.createElement('style')
420
+ node.id = STYLE_ID
421
+ node.textContent = STYLE
422
+ document.head.append(node)
423
+ return () => { node.remove() }
424
+ } catch {
425
+ return () => {}
426
+ }
427
+ }
428
+ //#endregion
429
+
430
+ //#region row discovery
431
+ /**
432
+ * Session id of a rendered row, read from the React fiber the renderer
433
+ * already attached. The row markup deliberately carries no id, so the fiber
434
+ * is the only exact source; the walk is bounded and every failure returns
435
+ * undefined, which makes the caller skip that row.
436
+ * @param node - a Session row element.
437
+ */
438
+ function sessionIdOf(node) {
439
+ try {
440
+ const key = Object.keys(node).find((name) => name.startsWith('__reactFiber$') || name.startsWith('__reactInternalInstance$'))
441
+ if (key === undefined) return undefined
442
+ let fiber = node[key]
443
+ for (let depth = 0; fiber !== null && fiber !== undefined && depth < 80; depth += 1) {
444
+ const props = fiber.memoizedProps
445
+ if (props !== null && typeof props === 'object') {
446
+ const candidate = props.node
447
+ if (candidate !== null && typeof candidate === 'object' && typeof candidate.id === 'string') return candidate.id
448
+ if (typeof props.sessionId === 'string') return props.sessionId
449
+ }
450
+ fiber = fiber.return
451
+ }
452
+ } catch {
453
+ // An unexpected fiber shape only costs this row its chip.
454
+ }
455
+ return undefined
456
+ }
457
+
458
+ /** Rendered Session rows, in document order. */
459
+ function sessionRows() {
460
+ try {
461
+ return [...document.querySelectorAll('[role="treeitem"][aria-selected]')]
462
+ } catch {
463
+ return []
464
+ }
465
+ }
466
+ //#endregion
467
+
468
+ //#region components
469
+ /**
470
+ * The nearest scrolling ancestor of a rendered row: the sidebar's list
471
+ * container. Chips are clipped to it, because a row scrolled out of that
472
+ * container still has a viewport-relative box and would otherwise paint a
473
+ * chip on top of unrelated UI outside the sidebar.
474
+ * @param node - a Session row element.
475
+ */
476
+ function scrollContainerOf(node) {
477
+ try {
478
+ let current = node === null || node === undefined ? null : node.parentElement
479
+ for (let depth = 0; current !== null && current !== undefined && depth < 12; depth += 1) {
480
+ const style = getComputedStyle(current)
481
+ const overflow = `${style.overflowY} ${style.overflow}`
482
+ if (/auto|scroll|hidden/.test(overflow) && current.clientHeight > 0) return current
483
+ current = current.parentElement
484
+ }
485
+ } catch {
486
+ // Without a container the layer falls back to clipping at the viewport.
487
+ }
488
+ return null
489
+ }
490
+
491
+ /** Viewport-relative clip rectangle for the chip layer, or 'none'. */
492
+ function clipFor(container) {
493
+ if (container === null || container === undefined) return 'none'
494
+ try {
495
+ const box = container.getBoundingClientRect()
496
+ const top = Math.max(0, box.top)
497
+ const left = Math.max(0, box.left)
498
+ const right = Math.max(0, window.innerWidth - Math.min(window.innerWidth, box.right))
499
+ const bottom = Math.max(0, window.innerHeight - Math.min(window.innerHeight, box.bottom))
500
+ return `inset(${top}px ${right}px ${bottom}px ${left}px)`
501
+ } catch {
502
+ return 'none'
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Stacking level the chip layer must paint at, read from the row's own
508
+ * ancestor chain.
509
+ *
510
+ * The layer decorates the sidebar, so it belongs immediately above the
511
+ * sidebar — not above the whole app, which would paint chips over dialogs.
512
+ * Desktop DSH keeps the sidebar in normal flow (`z-index: auto`), so the
513
+ * answer is 1 there. A phone adapter promotes the sidebar to a
514
+ * `position: fixed; z-index: 10000` drawer, and a layer left at 1 is painted
515
+ * behind that drawer's opaque background — which is exactly how the chips
516
+ * vanished on a phone while a DOM dump still showed them present.
517
+ *
518
+ * The OUTERMOST z-index wins: an inner one is already confined by the
519
+ * stacking context of the outer one, so the inner value is not the level
520
+ * that competes with the layer. An unreadable chain falls back to 1, which
521
+ * is where the layer sits on a stock desktop.
522
+ * @param row - a rendered Session row.
523
+ */
524
+ function layerZIndexOf(row) {
525
+ let level = 0
526
+ try {
527
+ let node = row === null || row === undefined ? null : row.parentElement
528
+ for (let depth = 0; node !== null && node !== undefined && node !== document.documentElement && depth < 40; depth += 1) {
529
+ const style = getComputedStyle(node)
530
+ if (style.position !== 'static') {
531
+ const value = Number.parseInt(style.zIndex, 10)
532
+ if (Number.isFinite(value)) level = value
533
+ }
534
+ node = node.parentElement
535
+ }
536
+ } catch {
537
+ // No readable chain: the flow position is still better than nothing.
538
+ }
539
+ return level + 1
540
+ }
541
+
542
+ /** One animation frame, falling back to a timer where rAF is absent. */
543
+ function requestFrame(run) {
544
+ return typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame(run) : window.setTimeout(run, 16)
545
+ }
546
+
547
+ /** Cancel a frame handed out by `requestFrame`, whichever mechanism it used. */
548
+ function cancelFrame(handle) {
549
+ if (handle === undefined) return
550
+ if (typeof window.cancelAnimationFrame === 'function') window.cancelAnimationFrame(handle)
551
+ else window.clearTimeout(handle)
552
+ }
553
+
554
+ /** One chip per marked row, positioned from the row's own box. */
555
+ function ChipsLayer(props) {
556
+ const { store, t } = props
557
+ const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
558
+ const [layer, setLayer] = React.useState({ items: [], clip: 'none', zIndex: 1 })
559
+
560
+ React.useEffect(() => {
561
+ let frame
562
+ let settling = 0
563
+ const step = () => {
564
+ frame = undefined
565
+ sync()
566
+ if (settling > 0) {
567
+ settling -= 1
568
+ frame = requestFrame(step)
569
+ }
570
+ }
571
+ /** Follow a moving ancestor for a while, then stop measuring. */
572
+ const follow = () => {
573
+ settling = SETTLE_FRAMES
574
+ if (frame === undefined) frame = requestFrame(step)
575
+ }
576
+ const sync = () => {
577
+ const rows = sessionRows()
578
+ const container = rows.length === 0 ? null : scrollContainerOf(rows[0])
579
+ const clip = clipFor(container)
580
+ const bounds = container === null ? null : container.getBoundingClientRect()
581
+ const items = []
582
+ for (const row of rows) {
583
+ const id = sessionIdOf(row)
584
+ if (id === undefined) continue
585
+ const color = snapshot.colors[id]
586
+ if (color === undefined) continue
587
+ let box
588
+ try {
589
+ box = row.getBoundingClientRect()
590
+ } catch {
591
+ continue
592
+ }
593
+ if (box.width === 0 && box.height === 0) continue
594
+ // Skip rows the container has scrolled out of view entirely.
595
+ if (bounds !== null && (box.bottom <= bounds.top || box.top >= bounds.bottom)) continue
596
+ items.push({
597
+ key: id,
598
+ left: box.left + CHIP_OFFSET,
599
+ top: box.top + (box.height - CHIP_HEIGHT) / 2,
600
+ background: cssOf(color),
601
+ })
602
+ }
603
+ setLayer({ items, clip, zIndex: rows.length === 0 ? 1 : layerZIndexOf(rows[0]) })
604
+ }
605
+ sync()
606
+ // Rows move with their scroll container and re-render on every list
607
+ // change, so follow both.
608
+ document.addEventListener('scroll', sync, true)
609
+ window.addEventListener('resize', sync)
610
+ // A CSS transition moves every row without any scroll, resize or DOM
611
+ // mutation: the phone adapter slides the whole sidebar in with a
612
+ // transform on the drawer while the rows themselves never change. Only
613
+ // transitions on an ancestor of the rows are followed, so a hover on one
614
+ // row does not start a measuring loop.
615
+ const onTransition = (event) => {
616
+ const target = event === null || event === undefined ? undefined : event.target
617
+ if (target === null || target === undefined || typeof target.contains !== 'function') return
618
+ const rows = sessionRows()
619
+ if (rows.length === 0 || rows.includes(target) || !target.contains(rows[0])) return
620
+ follow()
621
+ }
622
+ document.addEventListener('transitionrun', onTransition, true)
623
+ document.addEventListener('transitionend', onTransition, true)
624
+ let observer
625
+ try {
626
+ observer = new MutationObserver(sync)
627
+ observer.observe(document.body, { childList: true, subtree: true })
628
+ // The phone adapter opens its drawer by toggling a class on `body`.
629
+ // With transitions disabled the geometry still changes, just without a
630
+ // transition event to follow.
631
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class'] })
632
+ } catch {
633
+ observer = undefined
634
+ }
635
+ return () => {
636
+ cancelFrame(frame)
637
+ document.removeEventListener('scroll', sync, true)
638
+ window.removeEventListener('resize', sync)
639
+ document.removeEventListener('transitionrun', onTransition, true)
640
+ document.removeEventListener('transitionend', onTransition, true)
641
+ if (observer !== undefined) observer.disconnect()
642
+ }
643
+ }, [snapshot])
644
+
645
+ const node = React.createElement(
646
+ 'div',
647
+ {
648
+ className: 'dsh-sc-layer',
649
+ role: 'presentation',
650
+ 'aria-hidden': 'true',
651
+ title: t('layer'),
652
+ // Readable from a DOM dump: which build this device runs, and whether
653
+ // the Host settings channel is live. A device without this element is
654
+ // running a cached client from before marks moved to the Host.
655
+ 'data-dsh-sc-build': BUILD,
656
+ 'data-dsh-sc-status': snapshot.status,
657
+ 'data-dsh-sc-writable': String(snapshot.writable),
658
+ 'data-dsh-sc-marks': String(Object.keys(snapshot.colors).length),
659
+ 'data-dsh-sc-error': snapshot.error,
660
+ style: { clipPath: layer.clip, zIndex: layer.zIndex },
661
+ },
662
+ layer.items.map((chip) => React.createElement('span', {
663
+ key: chip.key,
664
+ className: 'dsh-sc-chip',
665
+ style: { left: `${chip.left}px`, top: `${chip.top}px`, background: chip.background },
666
+ })),
667
+ )
668
+
669
+ // DSH confines `shell.overlay` to a stacking context of its own (`z-index:
670
+ // 20` on the overlay layer), and nothing inside a stacking context can be
671
+ // raised above a sibling of it — so a sidebar that an adapter promotes to
672
+ // `z-index: 10000` always painted over the chips. A portal to the document
673
+ // body takes the layer out of that context, and the level measured above
674
+ // then decides how far up it sits. Without `react-dom` the layer stays in
675
+ // the slot, which is the old behaviour rather than a missing surface.
676
+ return typeof createPortal === 'function' && document.body !== undefined && document.body !== null
677
+ ? createPortal(node, document.body)
678
+ : node
679
+ }
680
+
681
+ /** Drag helper shared by the field, hue and alpha controls. */
682
+ function dragOn(node, read) {
683
+ const move = (event) => {
684
+ const box = node.getBoundingClientRect()
685
+ if (box.width === 0 || box.height === 0) return
686
+ read(clamp((event.clientX - box.left) / box.width, 0, 1), clamp((event.clientY - box.top) / box.height, 0, 1))
687
+ }
688
+ const stop = () => {
689
+ window.removeEventListener('pointermove', move)
690
+ window.removeEventListener('pointerup', stop)
691
+ }
692
+ node.addEventListener('pointerdown', (event) => {
693
+ event.preventDefault()
694
+ try {
695
+ node.setPointerCapture(event.pointerId)
696
+ } catch {
697
+ // Capture is a nicety; the window listeners still track the drag.
698
+ }
699
+ move(event)
700
+ window.addEventListener('pointermove', move)
701
+ window.addEventListener('pointerup', stop)
702
+ })
703
+ }
704
+
705
+ /**
706
+ * A text field that owns its keystrokes and commits on blur or Enter.
707
+ * Committing per keystroke would fight the user mid-entry (a partial hex
708
+ * value parses as a different colour), and re-rendering straight from props
709
+ * would remount the input and drop focus. `parse` returns the text to show,
710
+ * or undefined when the entry is not a value yet.
711
+ */
712
+ function Field(props) {
713
+ const { label, value, parse } = props
714
+ const [text, setText] = React.useState(value)
715
+ const editing = React.useRef(false)
716
+ React.useEffect(() => {
717
+ if (!editing.current) setText(value)
718
+ }, [value])
719
+ const commit = (raw) => {
720
+ const next = parse(raw)
721
+ setText(next === undefined ? value : next)
722
+ }
723
+ return React.createElement(
724
+ 'label',
725
+ { className: 'dsh-sc-field' },
726
+ React.createElement('span', null, label),
727
+ React.createElement('input', {
728
+ value: text,
729
+ spellCheck: false,
730
+ onFocus: () => { editing.current = true },
731
+ onChange: (event) => setText(event.target.value),
732
+ onBlur: (event) => { editing.current = false; commit(event.target.value) },
733
+ onKeyDown: (event) => {
734
+ if (event.key !== 'Enter') return
735
+ editing.current = false
736
+ commit(event.currentTarget.value)
737
+ event.currentTarget.blur()
738
+ },
739
+ }),
740
+ )
741
+ }
742
+
743
+ /** The system-style picker panel, positioned against its trigger. */
744
+ function ColorPanel(props) {
745
+ const { t, value, onChange, onClose, anchor } = props
746
+ const [draft, setDraft] = React.useState(value)
747
+ const [mine, setMine] = React.useState([])
748
+ const panelRef = React.useRef(null)
749
+ const [position, setPosition] = React.useState(null)
750
+
751
+ const commit = React.useCallback((next) => {
752
+ setDraft(next)
753
+ onChange(next)
754
+ }, [onChange])
755
+
756
+ // Clamp against the panel's MEASURED box, not its nominal width: the
757
+ // border and padding make the rendered box wider than `width`, and a
758
+ // nominal clamp let the panel run off the right edge of a narrow screen.
759
+ // It stays hidden until placed so it never flashes at the wrong corner.
760
+ React.useEffect(() => {
761
+ const node = panelRef.current
762
+ const rect = node === null ? null : node.getBoundingClientRect()
763
+ const margin = 8
764
+ const width = rect === null || rect.width === 0 ? 268 : rect.width
765
+ const height = rect === null || rect.height === 0 ? 500 : rect.height
766
+ const viewportWidth = window.innerWidth
767
+ const viewportHeight = window.innerHeight
768
+ let anchorBox
769
+ try {
770
+ anchorBox = anchor === null || anchor === undefined ? undefined : anchor.getBoundingClientRect()
771
+ } catch {
772
+ anchorBox = undefined
773
+ }
774
+ const left = anchorBox === undefined
775
+ ? margin
776
+ : clamp(anchorBox.left, margin, Math.max(margin, viewportWidth - width - margin))
777
+ const top = anchorBox === undefined
778
+ ? margin
779
+ : clamp(anchorBox.bottom + 6, margin, Math.max(margin, viewportHeight - height - margin))
780
+ setPosition({ left, top })
781
+ }, [anchor])
782
+
783
+ React.useEffect(() => {
784
+ const onKey = (event) => { if (event.key === 'Escape') onClose() }
785
+ const onDown = (event) => {
786
+ const node = panelRef.current
787
+ if (node !== null && !node.contains(event.target) && !(anchor !== null && anchor !== undefined && anchor.contains(event.target))) onClose()
788
+ }
789
+ document.addEventListener('keydown', onKey)
790
+ document.addEventListener('pointerdown', onDown, true)
791
+ return () => {
792
+ document.removeEventListener('keydown', onKey)
793
+ document.removeEventListener('pointerdown', onDown, true)
794
+ }
795
+ }, [anchor, onClose])
796
+
797
+ const svRef = React.useRef(null)
798
+ const hueRef = React.useRef(null)
799
+ const alphaRef = React.useRef(null)
800
+ // The panel mounts when it opens, so the drag handlers bind once. The
801
+ // latest draft and commit are read through refs: re-binding on every
802
+ // change would stack duplicate pointerdown listeners on the same node.
803
+ const draftRef = React.useRef(draft)
804
+ draftRef.current = draft
805
+ const commitRef = React.useRef(commit)
806
+ commitRef.current = commit
807
+ React.useEffect(() => {
808
+ const bound = [
809
+ [svRef.current, (x, y) => commitRef.current({ ...draftRef.current, s: x, v: 1 - y })],
810
+ [hueRef.current, (x) => commitRef.current({ ...draftRef.current, h: x * 360 })],
811
+ [alphaRef.current, (x) => commitRef.current({ ...draftRef.current, a: x })],
812
+ ]
813
+ for (const [node, read] of bound) {
814
+ if (node !== null && node !== undefined) dragOn(node, read)
815
+ }
816
+ }, [])
817
+
818
+ const [r, g, b] = hsvToRgb(draft.h, draft.s, draft.v)
819
+ const hueColor = `hsl(${draft.h} 100% 50%)`
820
+
821
+ /**
822
+ * A text field that owns its keystrokes and commits on blur or Enter.
823
+ * Committing per keystroke would fight the user mid-entry (a partial hex
824
+ * value parses as a different colour) and would remount the input.
825
+ */
826
+ const field = (label, key, text, parse) => React.createElement(Field, {
827
+ key,
828
+ label,
829
+ parse,
830
+ value: text,
831
+ })
832
+
833
+ return React.createElement(
834
+ 'div',
835
+ {
836
+ className: 'dsh-sc-panel',
837
+ ref: panelRef,
838
+ role: 'dialog',
839
+ 'aria-label': t('panelTitle'),
840
+ style: position === null
841
+ ? { left: '0px', top: '0px', visibility: 'hidden' }
842
+ : { left: `${position.left}px`, top: `${position.top}px` },
843
+ },
844
+ React.createElement('h4', { className: 'dsh-sc-title' }, t('mark')),
845
+ React.createElement(
846
+ 'div',
847
+ { className: 'dsh-sc-sv', ref: svRef, role: 'presentation', 'aria-label': t('saturation'), style: { background: hueColor } },
848
+ React.createElement('div', { className: 'dsh-sc-sv-w' }),
849
+ React.createElement('div', { className: 'dsh-sc-sv-b' }),
850
+ React.createElement('div', { className: 'dsh-sc-ring', style: { left: `${draft.s * 100}%`, top: `${(1 - draft.v) * 100}%` } }),
851
+ ),
852
+ React.createElement(
853
+ 'div',
854
+ { className: 'dsh-sc-row' },
855
+ React.createElement(
856
+ 'button',
857
+ {
858
+ type: 'button',
859
+ className: 'dsh-sc-drop',
860
+ title: t('eyedrop'),
861
+ 'aria-label': t('eyedrop'),
862
+ disabled: typeof window.EyeDropper !== 'function',
863
+ onClick: async () => {
864
+ try {
865
+ const picked = await new window.EyeDropper().open()
866
+ const next = parseHex(picked.sRGBHex)
867
+ if (next !== undefined) commit({ ...next, a: 1 })
868
+ } catch {
869
+ // The user dismissed the system picker.
870
+ }
871
+ },
872
+ },
873
+ React.createElement('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8 },
874
+ React.createElement('path', { d: 'M3 21l6.5-6.5' }),
875
+ React.createElement('path', { d: 'M14.5 4.5l5 5' }),
876
+ React.createElement('path', { d: 'M12 7l5 5-7.5 7.5H4.5v-5L12 7z' })),
877
+ ),
878
+ React.createElement(
879
+ 'div',
880
+ { className: 'dsh-sc-sliders' },
881
+ React.createElement('div', { className: 'dsh-sc-hue', ref: hueRef, role: 'presentation', 'aria-label': t('hue') },
882
+ React.createElement('div', { className: 'dsh-sc-knob', style: { left: `${(draft.h / 360) * 100}%` } })),
883
+ React.createElement('div', { className: 'dsh-sc-alpha', ref: alphaRef, role: 'presentation', 'aria-label': t('alpha') },
884
+ React.createElement('div', { className: 'dsh-sc-alpha-fill', style: { background: `linear-gradient(to right, transparent, ${hueColor})` } }),
885
+ React.createElement('div', { className: 'dsh-sc-knob', style: { left: `${draft.a * 100}%` } })),
886
+ ),
887
+ ),
888
+ React.createElement(
889
+ 'div',
890
+ { className: 'dsh-sc-fields' },
891
+ field(t('hex'), 'hex', toHex(draft), (raw) => {
892
+ const next = parseHex(raw)
893
+ if (next === undefined) return undefined
894
+ commit({ ...next, a: draft.a })
895
+ return toHex(next)
896
+ }),
897
+ field('R', 'r', String(r), (raw) => {
898
+ const next = clamp(Math.round(Number(raw) || 0), 0, 255)
899
+ commit({ ...rgbToHsv(next, g, b), a: draft.a })
900
+ return String(next)
901
+ }),
902
+ field('G', 'g', String(g), (raw) => {
903
+ const next = clamp(Math.round(Number(raw) || 0), 0, 255)
904
+ commit({ ...rgbToHsv(r, next, b), a: draft.a })
905
+ return String(next)
906
+ }),
907
+ field('B', 'b', String(b), (raw) => {
908
+ const next = clamp(Math.round(Number(raw) || 0), 0, 255)
909
+ commit({ ...rgbToHsv(r, g, next), a: draft.a })
910
+ return String(next)
911
+ }),
912
+ field('A', 'a', String(Math.round(draft.a * 100)), (raw) => {
913
+ const next = clamp(Math.round(Number(raw) || 0), 0, 100)
914
+ commit({ ...draft, a: next / 100 })
915
+ return String(next)
916
+ }),
917
+ ),
918
+ React.createElement(
919
+ 'div',
920
+ { className: 'dsh-sc-section' },
921
+ React.createElement('h4', null, t('theme')),
922
+ React.createElement('div', { className: 'dsh-sc-swatches' },
923
+ THEME.map(([hex, alpha], index) => React.createElement(
924
+ 'button',
925
+ {
926
+ key: `${hex}-${index}`,
927
+ type: 'button',
928
+ className: 'dsh-sc-swatch',
929
+ 'aria-label': index === THEME.length - 1 ? t('clear') : t('theme'),
930
+ onClick: () => commit({ ...parseHex(hex), a: alpha }),
931
+ },
932
+ React.createElement('i', { style: { background: cssOf({ ...parseHex(hex), a: alpha }) } }),
933
+ ))),
934
+ ),
935
+ React.createElement(
936
+ 'div',
937
+ { className: 'dsh-sc-section' },
938
+ React.createElement('h4', null, t('mine')),
939
+ React.createElement('div', { className: 'dsh-sc-swatches' },
940
+ mine.map((color, index) => React.createElement(
941
+ 'button',
942
+ { key: `mine-${index}`, type: 'button', className: 'dsh-sc-swatch', 'aria-label': t('custom'), onClick: () => commit(color) },
943
+ React.createElement('i', { style: { background: cssOf(color) } }),
944
+ )),
945
+ React.createElement('button', {
946
+ type: 'button',
947
+ className: 'dsh-sc-add',
948
+ 'aria-label': t('addMine'),
949
+ onClick: () => setMine((current) => (current.some((item) => toHex(item) === toHex(draft) && item.a === draft.a) ? current : [...current, draft])),
950
+ }, '+')),
951
+ ),
952
+ React.createElement(
953
+ 'div',
954
+ { className: 'dsh-sc-actions' },
955
+ React.createElement('button', {
956
+ type: 'button',
957
+ className: 'dsh-sc-btn',
958
+ onClick: () => { onChange(undefined); onClose() },
959
+ }, t('clear')),
960
+ ),
961
+ )
962
+ }
963
+
964
+ /** Header action: a swatch-plus-label button that opens the picker. */
965
+ function HeaderAction(props) {
966
+ const { store, t, args } = props
967
+ const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
968
+ const [open, setOpen] = React.useState(false)
969
+ const buttonRef = React.useRef(null)
970
+ // The slot is expected to pass the Session it sits in; accept the shapes a
971
+ // header action can plausibly receive so a rename upstream only costs the
972
+ // action instead of silently pinning the wrong Session.
973
+ const sessionId = args === undefined
974
+ ? (props.sessionId === undefined ? (props.session === undefined ? undefined : props.session.id) : props.sessionId)
975
+ : args.sessionId
976
+ if (sessionId === undefined) return null
977
+
978
+ const color = snapshot.colors[sessionId]
979
+ const start = color === undefined ? { h: 358, s: 0.68, v: 0.9, a: 1 } : color
980
+ // Marks come from the Host route. When it is not answering, say so on the
981
+ // control instead of looking like a Session that simply has no colour.
982
+ const offline = snapshot.ready !== true
983
+
984
+ return React.createElement(
985
+ React.Fragment,
986
+ null,
987
+ React.createElement(
988
+ 'button',
989
+ {
990
+ type: 'button',
991
+ ref: buttonRef,
992
+ className: 'dsh-sc-action',
993
+ 'aria-haspopup': 'dialog',
994
+ 'aria-expanded': open,
995
+ // No visible label: the control is a colour swatch, and its meaning
996
+ // is carried by the tooltip, the accessible name and the panel
997
+ // heading. A text label would also have to be legible on both the
998
+ // light and the dark header, which a swatch does not.
999
+ 'aria-label': color === undefined ? t('mark') : `${t('mark')}:${toHex(color)}`,
1000
+ title: `${offline ? t('offline') : (color === undefined ? t('hint') : `${t('mark')}:${toHex(color)}`)} · ${BUILD}`,
1001
+ onClick: () => setOpen((current) => !current),
1002
+ },
1003
+ offline
1004
+ ? React.createElement('span', { 'aria-hidden': 'true' }, '⚠')
1005
+ : color === undefined
1006
+ ? React.createElement('span', { 'aria-hidden': 'true' }, '🎨')
1007
+ : React.createElement('span', { className: 'dsh-sc-dot', 'aria-hidden': 'true', style: { background: cssOf(color) } }),
1008
+ ),
1009
+ open
1010
+ ? React.createElement(ColorPanel, {
1011
+ t,
1012
+ anchor: buttonRef.current,
1013
+ value: start,
1014
+ onChange: (next) => {
1015
+ if (next === undefined) store.clear(sessionId)
1016
+ else store.set(sessionId, next)
1017
+ },
1018
+ onClose: () => setOpen(false),
1019
+ })
1020
+ : null,
1021
+ )
1022
+ }
1023
+ //#endregion
1024
+
1025
+ /** Required services; both target slots are reached through `slots.inject`. */
1026
+ exports.inject = ['slots', 'locale', 'layout']
1027
+
1028
+ /**
1029
+ * Register the two surfaces once their slot declarations exist. Each
1030
+ * registration is isolated: a surface the running build does not declare
1031
+ * stays absent instead of failing the whole plugin.
1032
+ * @param ctx - client root context.
1033
+ */
1034
+ exports.apply = function apply(ctx) {
1035
+ const t = ctx.locale.bind(NS)
1036
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-session-colors: dictionaries')
1037
+ ctx.effect(installStyle, 'dsh-session-colors: stylesheet')
1038
+
1039
+ // Marks live in this plugin's own Host route. `settingsScope` cannot be
1040
+ // used here: DSH host-backs user settings only for loopback pages, so a
1041
+ // LAN or tunnel page would get a permanently unavailable scope.
1042
+ const store = createStore()
1043
+ ctx.effect(() => () => { store.dispose() }, 'dsh-session-colors: preference store')
1044
+
1045
+ // A device that shows nothing should be able to say why.
1046
+ try {
1047
+ const report = store.describe()
1048
+ console.info(`[dsh-session-colors] build=${BUILD} marks route status=${report.status} writable=${report.writable} marks=${report.marks}${report.error === '' ? '' : ` error=${report.error}`}`)
1049
+ } catch {
1050
+ // Diagnostics must never break the plugin.
1051
+ }
1052
+
1053
+ const seats = {}
1054
+ const seat = (name, register) => {
1055
+ try {
1056
+ ctx.slots.inject(name, register)
1057
+ seats[name] = 'active'
1058
+ } catch {
1059
+ seats[name] = 'unavailable'
1060
+ }
1061
+ }
1062
+
1063
+ // Registered as a right-aligned header utility, not as a title-adjacent
1064
+ // action. The title cluster is `flex: 1 1 0%` but cannot shrink below its
1065
+ // content, so an extra action there overflows straight into the utilities
1066
+ // cluster — on a narrow header that put the colour swatch on top of the
1067
+ // model picker. Utilities are right-aligned, so the flexible title
1068
+ // absorbs the width instead of two controls colliding.
1069
+ seat('conversation.session.header.utilities', () => ctx.slots.register(
1070
+ { name: 'conversation.session.header.utilities', id: ACTION_ID, order: 40, locale: NS },
1071
+ (props) => React.createElement(HeaderAction, {
1072
+ ...props,
1073
+ store,
1074
+ t,
1075
+ args: { sessionId: props.sessionId },
1076
+ }),
1077
+ ))
1078
+ seat('shell.overlay', () => ctx.slots.register(
1079
+ { name: 'shell.overlay', id: OVERLAY_ID, order: 100, label: () => t('layer'), locale: NS },
1080
+ (props) => React.createElement(ChipsLayer, { ...props, store, t }),
1081
+ ))
1082
+ exports.seats = seats
1083
+ }
1084
+
1085
+ exports.NS = NS
1086
+ exports.ACTION_ID = ACTION_ID
1087
+ exports.OVERLAY_ID = OVERLAY_ID
1088
+ exports.MARKS_ROUTE = MARKS_ROUTE
1089
+ exports.PEER_POLL_MS = PEER_POLL_MS
1090
+ exports.BUILD = BUILD
1091
+ exports.CHIP_WIDTH = CHIP_WIDTH
1092
+ exports.CHIP_HEIGHT = CHIP_HEIGHT
1093
+ exports.CHIP_OFFSET = CHIP_OFFSET
1094
+ exports.SETTLE_FRAMES = SETTLE_FRAMES
1095
+ // Exported for the unit tests: the store, its key scheme and the row lookup
1096
+ // are the parts worth pinning down without a browser.
1097
+ exports.createStore = createStore
1098
+ exports.readMarks = readMarks
1099
+ exports.isColor = isColor
1100
+ exports.sessionIdOf = sessionIdOf
1101
+ exports.layerZIndexOf = layerZIndexOf
1102
+ exports.hsvToRgb = hsvToRgb
1103
+ exports.rgbToHsv = rgbToHsv
1104
+ exports.toHex = toHex
1105
+ exports.parseHex = parseHex
1106
+ exports.cssOf = cssOf
1107
+ return module.exports
1108
+ },
1109
+ })