@devchitchat/chat 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ <h1 class="ds-page-title">Tokens</h1>
2
+ <p class="ds-page-subtitle">
3
+ CSS custom properties that define the visual language. All tokens are defined on
4
+ <code>:root</code> and override per theme. Switch the theme picker to see them update live.
5
+ </p>
6
+
7
+ <!-- ── Colors ────────────────────────────────────────────────────────────── -->
8
+ <div class="ds-section">
9
+ <h2 class="ds-section-title">Backgrounds</h2>
10
+ <p class="ds-section-desc">Layered surfaces create the sense of depth (HIG: Depth).</p>
11
+ <div class="token-grid">
12
+ <div class="token-swatch">
13
+ <div class="token-swatch-preview" style="background: var(--bg-base)"></div>
14
+ <div class="token-swatch-label">
15
+ <span class="token-swatch-name">--bg-base</span>
16
+ <span class="token-swatch-value" data-css-var="--bg-base">—</span>
17
+ </div>
18
+ </div>
19
+ <div class="token-swatch">
20
+ <div class="token-swatch-preview" style="background: var(--bg-sidebar)"></div>
21
+ <div class="token-swatch-label">
22
+ <span class="token-swatch-name">--bg-sidebar</span>
23
+ <span class="token-swatch-value" data-css-var="--bg-sidebar">—</span>
24
+ </div>
25
+ </div>
26
+ <div class="token-swatch">
27
+ <div class="token-swatch-preview" style="background: var(--bg-elevated)"></div>
28
+ <div class="token-swatch-label">
29
+ <span class="token-swatch-name">--bg-elevated</span>
30
+ <span class="token-swatch-value" data-css-var="--bg-elevated">—</span>
31
+ </div>
32
+ </div>
33
+ <div class="token-swatch">
34
+ <div class="token-swatch-preview" style="background: var(--bg-input)"></div>
35
+ <div class="token-swatch-label">
36
+ <span class="token-swatch-name">--bg-input</span>
37
+ <span class="token-swatch-value" data-css-var="--bg-input">—</span>
38
+ </div>
39
+ </div>
40
+ <div class="token-swatch">
41
+ <div class="token-swatch-preview" style="background: var(--bg-hover)"></div>
42
+ <div class="token-swatch-label">
43
+ <span class="token-swatch-name">--bg-hover</span>
44
+ <span class="token-swatch-value" data-css-var="--bg-hover">—</span>
45
+ </div>
46
+ </div>
47
+ <div class="token-swatch">
48
+ <div class="token-swatch-preview" style="background: var(--bg-active)"></div>
49
+ <div class="token-swatch-label">
50
+ <span class="token-swatch-name">--bg-active</span>
51
+ <span class="token-swatch-value" data-css-var="--bg-active">—</span>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="ds-section">
58
+ <h2 class="ds-section-title">Text</h2>
59
+ <p class="ds-section-desc">Three levels of hierarchy. Use muted only for metadata, never for actionable content.</p>
60
+ <div class="ds-canvas">
61
+ <p style="font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0 0 8px;">
62
+ --text-primary — Channel name, message handle, body text
63
+ </p>
64
+ <p style="font-size: 14px; color: var(--text-secondary); margin: 0 0 8px;">
65
+ --text-secondary — Descriptions, labels, nav items at rest
66
+ </p>
67
+ <p style="font-size: 13px; color: var(--text-muted); margin: 0;">
68
+ --text-muted — Timestamps, section headers, placeholder text
69
+ </p>
70
+ </div>
71
+ </div>
72
+
73
+ <div class="ds-section">
74
+ <h2 class="ds-section-title">Accent &amp; Status</h2>
75
+ <div class="token-grid">
76
+ <div class="token-swatch">
77
+ <div class="token-swatch-preview" style="background: var(--accent)"></div>
78
+ <div class="token-swatch-label">
79
+ <span class="token-swatch-name">--accent</span>
80
+ <span class="token-swatch-value" data-css-var="--accent">—</span>
81
+ </div>
82
+ </div>
83
+ <div class="token-swatch">
84
+ <div class="token-swatch-preview" style="background: var(--color-success)"></div>
85
+ <div class="token-swatch-label">
86
+ <span class="token-swatch-name">--color-success</span>
87
+ <span class="token-swatch-value" data-css-var="--color-success">—</span>
88
+ </div>
89
+ </div>
90
+ <div class="token-swatch">
91
+ <div class="token-swatch-preview" style="background: var(--color-danger)"></div>
92
+ <div class="token-swatch-label">
93
+ <span class="token-swatch-name">--color-danger</span>
94
+ <span class="token-swatch-value" data-css-var="--color-danger">—</span>
95
+ </div>
96
+ </div>
97
+ <div class="token-swatch">
98
+ <div class="token-swatch-preview" style="background: var(--color-warning)"></div>
99
+ <div class="token-swatch-label">
100
+ <span class="token-swatch-name">--color-warning</span>
101
+ <span class="token-swatch-value" data-css-var="--color-warning">—</span>
102
+ </div>
103
+ </div>
104
+ <div class="token-swatch">
105
+ <div class="token-swatch-preview" style="background: var(--border)"></div>
106
+ <div class="token-swatch-label">
107
+ <span class="token-swatch-name">--border</span>
108
+ <span class="token-swatch-value" data-css-var="--border">—</span>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+
114
+ <hr class="ds-divider">
115
+
116
+ <!-- ── Typography ─────────────────────────────────────────────────────────── -->
117
+ <div class="ds-section">
118
+ <h2 class="ds-section-title">Typography</h2>
119
+ <p class="ds-section-desc">System fonts. SF Pro on Apple, Segoe UI on Windows — no web font download required.</p>
120
+
121
+ <div class="ds-canvas">
122
+ <div class="token-row">
123
+ <div class="token-row-preview" style="background: var(--bg-hover); display: flex; align-items: center; justify-content: center;">
124
+ <span style="font-family: var(--font-sans); font-size: 13px; font-weight: 600;">Aa</span>
125
+ </div>
126
+ <span class="token-row-name">--font-sans</span>
127
+ <span class="token-row-value">-apple-system, SF Pro Text, system-ui, Segoe UI, sans-serif</span>
128
+ </div>
129
+ <div class="token-row">
130
+ <div class="token-row-preview" style="background: var(--bg-hover); display: flex; align-items: center; justify-content: center;">
131
+ <span style="font-family: var(--font-mono); font-size: 13px; font-weight: 600;">01</span>
132
+ </div>
133
+ <span class="token-row-name">--font-mono</span>
134
+ <span class="token-row-value">SF Mono, ui-monospace, Cascadia Code, Fira Code, JetBrains Mono</span>
135
+ </div>
136
+ </div>
137
+
138
+ <div class="ds-canvas" style="margin-top: 12px;">
139
+ <p style="font-size: 20px; font-weight: 700; margin: 0 0 6px; line-height: 1.2;">Page title — 20px/700</p>
140
+ <p style="font-size: 17px; font-weight: 600; margin: 0 0 6px; line-height: 1.3;">Section title — 17px/600</p>
141
+ <p style="font-size: 15px; font-weight: 500; margin: 0 0 6px;">Message handle — 15px/500</p>
142
+ <p style="font-size: 14px; color: var(--text-secondary); margin: 0 0 6px; line-height: 1.5;">Body text — 14px/400, --text-secondary for descriptions</p>
143
+ <p style="font-size: 13px; color: var(--text-secondary); margin: 0 0 6px;">UI label — 13px/400, most controls</p>
144
+ <p style="font-size: 12px; color: var(--text-muted); margin: 0 0 6px;">Timestamp / metadata — 12px/400, --text-muted</p>
145
+ <p style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin: 0;">Section label — 11px/600 uppercase</p>
146
+ </div>
147
+ </div>
148
+
149
+ <hr class="ds-divider">
150
+
151
+ <!-- ── Radii ──────────────────────────────────────────────────────────────── -->
152
+ <div class="ds-section">
153
+ <h2 class="ds-section-title">Border Radius</h2>
154
+ <p class="ds-section-desc">Consistent rounding communicates surface type and elevation.</p>
155
+ <div class="ds-canvas ds-canvas--grid">
156
+ <div style="text-align: center;">
157
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-sm); margin: 0 auto 8px;"></div>
158
+ <code style="font-size: 11px;">--radius-sm<br>6px</code>
159
+ </div>
160
+ <div style="text-align: center;">
161
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-md); margin: 0 auto 8px;"></div>
162
+ <code style="font-size: 11px;">--radius-md<br>10px</code>
163
+ </div>
164
+ <div style="text-align: center;">
165
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-lg); margin: 0 auto 8px;"></div>
166
+ <code style="font-size: 11px;">--radius-lg<br>14px</code>
167
+ </div>
168
+ <div style="text-align: center;">
169
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-xl); margin: 0 auto 8px;"></div>
170
+ <code style="font-size: 11px;">--radius-xl<br>20px</code>
171
+ </div>
172
+ <div style="text-align: center;">
173
+ <div style="width: 60px; height: 60px; background: var(--accent); border-radius: var(--radius-pill); margin: 0 auto 8px;"></div>
174
+ <code style="font-size: 11px;">--radius-pill<br>999px</code>
175
+ </div>
176
+ </div>
177
+ <div class="ds-annotation">
178
+ <strong>Usage guide:</strong> sm → nav items, code blocks, small chips. md → inputs, popovers.
179
+ lg → panels, modals, cards. xl → large overlays, sheets. pill → tags, reaction pills, primary buttons.
180
+ </div>
181
+ </div>
182
+
183
+ <hr class="ds-divider">
184
+
185
+ <!-- ── Shadows ─────────────────────────────────────────────────────────────── -->
186
+ <div class="ds-section">
187
+ <h2 class="ds-section-title">Shadows</h2>
188
+ <p class="ds-section-desc">Used for elevation — overlays, floating elements, modals.</p>
189
+ <div class="ds-canvas ds-canvas--grid" style="gap: 32px;">
190
+ <div style="text-align: center;">
191
+ <div style="width: 80px; height: 80px; background: var(--bg-elevated); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); margin: 0 auto 12px;"></div>
192
+ <code style="font-size: 11px; color: var(--text-muted);">--shadow-sm<br>nav items, small chips</code>
193
+ </div>
194
+ <div style="text-align: center;">
195
+ <div style="width: 80px; height: 80px; background: var(--bg-elevated); border-radius: var(--radius-md); box-shadow: var(--shadow-md); margin: 0 auto 12px;"></div>
196
+ <code style="font-size: 11px; color: var(--text-muted);">--shadow-md<br>popovers, context menus</code>
197
+ </div>
198
+ <div style="text-align: center;">
199
+ <div style="width: 80px; height: 80px; background: var(--bg-elevated); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); margin: 0 auto 12px;"></div>
200
+ <code style="font-size: 11px; color: var(--text-muted);">--shadow-lg<br>modals, sheets</code>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <hr class="ds-divider">
206
+
207
+ <!-- ── Motion ─────────────────────────────────────────────────────────────── -->
208
+ <div class="ds-section">
209
+ <h2 class="ds-section-title">Motion</h2>
210
+ <p class="ds-section-desc">One transition token. Use it for all interactive state changes.</p>
211
+ <div class="ds-canvas">
212
+ <div class="token-row" style="border: none; padding: 0;">
213
+ <span class="token-row-name">--transition</span>
214
+ <span class="token-row-value">200ms cubic-bezier(0.25, 0.1, 0.25, 1)</span>
215
+ </div>
216
+ </div>
217
+ <div class="ds-annotation">
218
+ <strong>Refactoring opportunity:</strong> Several components hardcode their own transition values
219
+ instead of using <code>var(--transition)</code>. When adding new interactive elements, always
220
+ use the token. Consider a pass through <code>base.css</code> to replace hardcoded transitions.
221
+ </div>
222
+ </div>
223
+
224
+ <script>
225
+ // Read computed CSS variable values and show them under swatches
226
+ document.querySelectorAll('[data-css-var]').forEach(el => {
227
+ const update = () => {
228
+ const val = getComputedStyle(document.documentElement)
229
+ .getPropertyValue(el.dataset.cssVar).trim()
230
+ el.textContent = val || '—'
231
+ }
232
+ update()
233
+ // Re-read on theme change (theme.js swaps the stylesheet link)
234
+ document.getElementById('theme-stylesheet')?.addEventListener('load', update)
235
+ })
236
+ </script>
@@ -19,11 +19,13 @@ import { ThreadPanelView } from './views/ThreadPanelView.js'
19
19
  import { ComposerView } from './views/ComposerView.js'
20
20
  import { SidebarView } from './views/SidebarView.js'
21
21
  import { CallView } from './views/CallView.js'
22
+ import { ChatHeaderView } from './views/ChatHeaderView.js'
22
23
  import { cancelActiveEdit } from './views/shared/MessageInteractions.js'
23
24
  import { closeEmojiPicker } from './views/shared/EmojiPickerSingleton.js'
24
25
  import { getSettings, syncFromServer, patchSettings } from './settings-sync.js'
25
26
  import { initSwipeNav } from './swipe-nav.js'
26
27
  import { initRouter } from './router.js'
28
+ import { attachResizeHandle } from './resizable.js'
27
29
 
28
30
  const BASE_PATH = window.__BASE_PATH__ ?? ''
29
31
 
@@ -68,7 +70,7 @@ if (chatPanelEl) {
68
70
 
69
71
  const wsController = new WebSocketController(model, `${BASE_PATH}/ws`)
70
72
  const ws = wsController.ws
71
- const chatController = new ChatController(model, ws)
73
+ new ChatController(model, ws)
72
74
 
73
75
  // ── 4. Views ──────────────────────────────────────────────────────────────────
74
76
 
@@ -85,12 +87,24 @@ if (chatPanelEl) {
85
87
  const composerEl = chatPanelEl.querySelector('.composer')
86
88
  const threadPanelEl = document.getElementById('thread-panel')
87
89
 
90
+ const headerEl = chatPanelEl.querySelector('.chat-header')
91
+ if (headerEl) {
92
+ new ChatHeaderView(model, headerEl)
93
+ }
94
+
88
95
  if (messagesEl) {
89
96
  new MessageListView(model, messagesEl, sentinelEl)
90
97
  }
91
98
 
92
99
  if (threadPanelEl) {
93
100
  new ThreadPanelView(model, threadPanelEl)
101
+ attachResizeHandle(threadPanelEl, {
102
+ edge: 'left',
103
+ cssVar: '--thread-panel-width',
104
+ min: 260,
105
+ max: 640,
106
+ prefKey: 'thread_panel_width',
107
+ })
94
108
  }
95
109
 
96
110
  if (composerEl) {
@@ -98,11 +112,32 @@ if (chatPanelEl) {
98
112
  }
99
113
 
100
114
  // CallView (WebRTC) — only if call UI elements are present
101
- if (document.getElementById('tile-panel') || document.getElementById('btn-start-call')) {
102
- new CallView(model, ws, chatPanelEl)
115
+ const tilePanelEl = document.getElementById('tile-panel')
116
+ if (tilePanelEl || document.getElementById('btn-start-call')) {
117
+ new CallView(model, ws)
118
+ }
119
+ if (tilePanelEl) {
120
+ attachResizeHandle(tilePanelEl, {
121
+ edge: 'left',
122
+ cssVar: '--tile-panel-width',
123
+ min: 200,
124
+ max: 640,
125
+ prefKey: 'tile_panel_width',
126
+ })
103
127
  }
104
128
  }
105
129
 
130
+ // Sidebar resize — desktop only (mobile sidebar is full-screen overlay)
131
+ if (sidebarEl && window.matchMedia('(min-width: 769px)').matches) {
132
+ attachResizeHandle(sidebarEl, {
133
+ edge: 'right',
134
+ cssVar: '--sidebar-width',
135
+ min: 180,
136
+ max: 480,
137
+ prefKey: 'sidebar_width',
138
+ })
139
+ }
140
+
106
141
  // ── 5. SPA navigation ─────────────────────────────────────────────────────────
107
142
 
108
143
  // router.js morphs the DOM and dispatches 'chatpanel:navigated'.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * resizable.js — attach a drag-to-resize handle to a panel edge.
3
+ *
4
+ * Usage:
5
+ * attachResizeHandle(el, {
6
+ * edge: 'right' | 'left', // which edge gets the handle
7
+ * cssVar: '--sidebar-width', // CSS custom property to update on :root
8
+ * min: 180, // minimum width in px
9
+ * max: 600, // maximum width in px
10
+ * prefKey: 'sidebar_width', // settings-sync pref key (omit to skip persistence)
11
+ * })
12
+ */
13
+
14
+ import { getPref, setPref } from './settings-sync.js'
15
+
16
+ export function attachResizeHandle(el, { edge, cssVar, min = 160, max = 700, prefKey } = {}) {
17
+ // Restore persisted width
18
+ if (prefKey) {
19
+ const saved = getPref(prefKey)
20
+ if (saved != null) {
21
+ const px = parseInt(saved, 10)
22
+ if (px >= min && px <= max) {
23
+ document.documentElement.style.setProperty(cssVar, `${px}px`)
24
+ }
25
+ }
26
+ }
27
+
28
+ const handle = document.createElement('div')
29
+ handle.className = `resize-handle resize-handle--${edge}`
30
+ handle.setAttribute('aria-hidden', 'true')
31
+ el.appendChild(handle)
32
+
33
+ let startX = 0
34
+ let startWidth = 0
35
+
36
+ const onMove = e => {
37
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
38
+ const dx = clientX - startX
39
+ const newWidth = edge === 'right'
40
+ ? Math.max(min, Math.min(max, startWidth + dx))
41
+ : Math.max(min, Math.min(max, startWidth - dx))
42
+ document.documentElement.style.setProperty(cssVar, `${newWidth}px`)
43
+ }
44
+
45
+ const onEnd = () => {
46
+ document.removeEventListener('mousemove', onMove)
47
+ document.removeEventListener('mouseup', onEnd)
48
+ document.removeEventListener('touchmove', onMove)
49
+ document.removeEventListener('touchend', onEnd)
50
+ document.body.style.userSelect = ''
51
+ document.body.style.cursor = ''
52
+
53
+ if (prefKey) {
54
+ const current = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
55
+ setPref(prefKey, parseInt(current, 10))
56
+ }
57
+ }
58
+
59
+ const onStart = e => {
60
+ if (e.button != null && e.button !== 0) return
61
+ e.preventDefault()
62
+ startX = e.touches ? e.touches[0].clientX : e.clientX
63
+ startWidth = el.getBoundingClientRect().width
64
+ document.body.style.userSelect = 'none'
65
+ document.body.style.cursor = 'col-resize'
66
+ document.addEventListener('mousemove', onMove)
67
+ document.addEventListener('mouseup', onEnd)
68
+ document.addEventListener('touchmove', onMove, { passive: false })
69
+ document.addEventListener('touchend', onEnd)
70
+ }
71
+
72
+ handle.addEventListener('mousedown', onStart)
73
+ handle.addEventListener('touchstart', onStart, { passive: false })
74
+ }
@@ -86,8 +86,11 @@ export class RtcPeerManager {
86
86
  }
87
87
 
88
88
  // Video stream → render tile + ensure audio (video stream may carry audio track)
89
- const tileId = `${peerId}-${event.transceiver?.mid ?? 'cam'}`
90
- const label = this.#displayNames.get(peerId) ?? peerId
89
+ // Use semantic slot name (cam/screen) so WS signaling can address tiles by kind.
90
+ const slots = this.#getTransceiverSlots(pc)
91
+ const slotName = event.transceiver === slots.screen ? 'screen' : 'cam'
92
+ const tileId = `${peerId}-${slotName}`
93
+ const label = this.#displayNames.get(peerId) ?? peerId
91
94
  this.#handlers.onTrack(peerId, tileId, stream, label)
92
95
  this.#handlers.onAudio(peerId, stream)
93
96
  }
@@ -1,25 +1,63 @@
1
1
  /**
2
- * settings-sync.js — client-side settings: localStorage + background server sync.
2
+ * settings-sync.js — all client-side persistence in one place.
3
3
  *
4
- * This module owns all localStorage reads/writes and server sync. Islands import
5
- * from here they never touch localStorage or the API directly.
4
+ * Two namespaces:
5
+ * settings synced to server (last_channel_id, mobile_chat_open, …)
6
+ * prefs — local-only UI preferences (theme, panel widths, devices, …)
6
7
  *
7
- * Storage shape: { settings: { last_channel_id, mobile_chat_open }, updated_at: number }
8
+ * Callers never touch localStorage directly they use the exports below.
9
+ *
10
+ * Storage keys:
11
+ * devchitchat_settings { settings: {…}, updated_at: number }
12
+ * devchitchat_prefs { theme, sidebar_width, thread_panel_width,
13
+ * tile_panel_width, tile_layout, devices, … }
8
14
  */
9
15
 
10
- const STORAGE_KEY = 'devchitchat_settings'
16
+ const SETTINGS_KEY = 'devchitchat_settings'
17
+ const PREFS_KEY = 'devchitchat_prefs'
11
18
  const BASE_PATH = window.__BASE_PATH__ ?? ''
12
19
 
13
20
  function readLocal() {
14
21
  try {
15
- return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
22
+ return JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}')
16
23
  } catch {
17
24
  return {}
18
25
  }
19
26
  }
20
27
 
21
28
  function writeLocal(data) {
22
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
29
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(data))
30
+ }
31
+
32
+ // ── Local-only UI preferences ─────────────────────────────────────────────────
33
+
34
+ function readPrefs() {
35
+ try {
36
+ return JSON.parse(localStorage.getItem(PREFS_KEY) ?? '{}')
37
+ } catch {
38
+ return {}
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Read a UI preference. Returns `defaultValue` when the key has never been set.
44
+ * @param {string} key
45
+ * @param {*} [defaultValue]
46
+ */
47
+ export function getPref(key, defaultValue = null) {
48
+ const prefs = readPrefs()
49
+ return key in prefs ? prefs[key] : defaultValue
50
+ }
51
+
52
+ /**
53
+ * Write one or more UI preferences.
54
+ * @param {string|Record<string,*>} keyOrPatch — key string or { key: value } map
55
+ * @param {*} [value] — value when keyOrPatch is a string
56
+ */
57
+ export function setPref(keyOrPatch, value) {
58
+ const prefs = readPrefs()
59
+ const patch = typeof keyOrPatch === 'string' ? { [keyOrPatch]: value } : keyOrPatch
60
+ localStorage.setItem(PREFS_KEY, JSON.stringify({ ...prefs, ...patch }))
23
61
  }
24
62
 
25
63
  // Returns current settings object (instant, synchronous)
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Each theme is a separate CSS file in /themes/<name>.css.
5
5
  * The <html data-theme> attribute is set so themes can also use attribute selectors.
6
+ * Theme preference is stored via settings-sync getPref/setPref ('theme' key).
6
7
  */
8
+ import { getPref, setPref } from './settings-sync.js'
9
+
7
10
  const THEMES = ['dark', 'light', 'ocean', 'forest', 'rose']
8
- const STORAGE_KEY = 'devchitchat_theme'
9
11
  const BASE_PATH = window.__BASE_PATH__ ?? ''
10
12
  const stylesheet = document.getElementById('theme-stylesheet')
11
13
  const picker = document.getElementById('theme-picker')
@@ -15,13 +17,13 @@ function applyTheme(name) {
15
17
  document.documentElement.dataset.theme = theme
16
18
  if (stylesheet) stylesheet.href = `${BASE_PATH}/themes/${theme}.css`
17
19
  if (picker) picker.value = theme
18
- localStorage.setItem(STORAGE_KEY, theme)
20
+ setPref('theme', theme)
19
21
  }
20
22
 
21
23
  // Restore saved theme immediately (before paint)
22
- applyTheme(localStorage.getItem(STORAGE_KEY) ?? 'dark')
24
+ applyTheme(getPref('theme', 'dark'))
23
25
 
24
26
  // Wire picker
25
27
  if (picker) {
26
- picker.addEventListener('change', (e) => applyTheme(e.target.value))
28
+ picker.addEventListener('change', e => applyTheme(e.target.value))
27
29
  }