dsh-tiddlywiki 0.7.3 → 0.8.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.7.3",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -1,121 +1,127 @@
1
- /**
2
- * Floating popup iframe that loads TiddlyWiki's NATIVE editor for a draft
3
- * (quick-note "✏️ 在 TW 中编辑"). A small draggable + resizable overlay with its
4
- * own iframe pointed at `twUrl#<draftTitle>`: the fragment-only navigation
5
- * triggers TW's hashchange, which opens the draft in the story, and because the
6
- * draft tiddler carries `draft.of` the story renders the native EditTemplate.
7
- *
8
- * Independent of the center panel — a separate floating window so the user can
9
- * edit a note without leaving the chat context.
10
- *
11
- * @module dsh-tiddlywiki/client/editor-popup
12
- */
13
-
14
- let root: HTMLDivElement | undefined
15
- let frame: HTMLIFrameElement | undefined
16
- let titleEl: HTMLSpanElement | undefined
17
-
18
- /** Open (create on first use) the popup and load `url` (twUrl#draftTitle). */
19
- export function openEditorPopup(url: string, label: string): void {
20
- ensurePopup()
21
- if (root === undefined || frame === undefined) return
22
- if (titleEl !== undefined) titleEl.textContent = `TiddlyWiki 编辑器 · ${label}`
23
- root.style.display = ''
24
- // Same-base fragment navigation reloads nothing; a changed base (restart on a
25
- // new port) reloads the app and TW still opens the draft from the hash.
26
- frame.src = url
27
- }
28
-
29
- /** Remove the popup DOM entirely (plugin dispose). */
30
- export function disposeEditorPopup(): void {
31
- root?.remove()
32
- root = undefined
33
- frame = undefined
34
- titleEl = undefined
35
- }
36
-
37
- function ensurePopup(): void {
38
- if (root !== undefined && frame !== undefined) return
39
-
40
- root = document.createElement('div')
41
- root.className = 'dsh-tw-editor-popup'
42
- root.style.display = 'none'
43
-
44
- const bar = document.createElement('div')
45
- bar.className = 'dsh-tw-editor-bar'
46
- titleEl = document.createElement('span')
47
- titleEl.className = 'dsh-tw-editor-title'
48
- titleEl.textContent = 'TiddlyWiki 编辑器'
49
- const close = document.createElement('button')
50
- close.type = 'button'
51
- close.className = 'dsh-tw-editor-close'
52
- close.textContent = '✕'
53
- close.title = '关闭'
54
- bar.append(titleEl, close)
55
-
56
- frame = document.createElement('iframe')
57
- frame.className = 'dsh-tw-editor-frame'
58
- frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups')
59
- frame.title = 'TiddlyWiki 编辑器'
60
-
61
- const resize = document.createElement('div')
62
- resize.className = 'dsh-tw-editor-resize'
63
- resize.title = '拖拽调整大小'
64
-
65
- root.append(bar, frame, resize)
66
- document.body.append(root)
67
-
68
- close.addEventListener('click', () => {
69
- if (root !== undefined) root.style.display = 'none'
70
- })
71
-
72
- // Drag by the title bar (un-center by setting explicit left/top + margin 0).
73
- bar.addEventListener('mousedown', (event) => {
74
- if (event.button !== 0 || root === undefined) return
75
- event.preventDefault()
76
- const rect = root.getBoundingClientRect()
77
- const startX = event.clientX
78
- const startY = event.clientY
79
- const baseLeft = rect.left
80
- const baseTop = rect.top
81
- const onMove = (ev: MouseEvent): void => {
82
- if (root === undefined) return
83
- root.style.left = `${baseLeft + ev.clientX - startX}px`
84
- root.style.top = `${baseTop + ev.clientY - startY}px`
85
- root.style.margin = '0'
86
- root.style.right = 'auto'
87
- root.style.bottom = 'auto'
88
- }
89
- const onUp = (): void => {
90
- window.removeEventListener('mousemove', onMove)
91
- window.removeEventListener('mouseup', onUp)
92
- }
93
- window.addEventListener('mousemove', onMove)
94
- window.addEventListener('mouseup', onUp)
95
- })
96
-
97
- // Resize from the bottom-right corner.
98
- resize.addEventListener('mousedown', (event) => {
99
- if (event.button !== 0 || root === undefined) return
100
- event.preventDefault()
101
- event.stopPropagation()
102
- const rect = root.getBoundingClientRect()
103
- const startX = event.clientX
104
- const startY = event.clientY
105
- const baseW = rect.width
106
- const baseH = rect.height
107
- const onMove = (ev: MouseEvent): void => {
108
- if (root === undefined) return
109
- root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
110
- root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
111
- root.style.right = 'auto'
112
- root.style.bottom = 'auto'
113
- }
114
- const onUp = (): void => {
115
- window.removeEventListener('mousemove', onMove)
116
- window.removeEventListener('mouseup', onUp)
117
- }
118
- window.addEventListener('mousemove', onMove)
119
- window.addEventListener('mouseup', onUp)
120
- })
121
- }
1
+ /**
2
+ * Floating popup iframe that loads TiddlyWiki's NATIVE editor for a draft
3
+ * (quick-note "✏️ 在 TW 中编辑"). A small draggable + resizable overlay with its
4
+ * own iframe pointed at `twUrl#<draftTitle>`: the fragment-only navigation
5
+ * triggers TW's hashchange, which opens the draft in the story, and because the
6
+ * draft tiddler carries `draft.of` the story renders the native EditTemplate.
7
+ *
8
+ * Independent of the center panel — a separate floating window so the user can
9
+ * edit a note without leaving the chat context.
10
+ *
11
+ * @module dsh-tiddlywiki/client/editor-popup
12
+ */
13
+ import { attachThemeSync } from './theme-sync.ts'
14
+
15
+ let root: HTMLDivElement | undefined
16
+ let frame: HTMLIFrameElement | undefined
17
+ let titleEl: HTMLSpanElement | undefined
18
+ let themeSyncDispose: (() => void) | undefined
19
+
20
+ /** Open (create on first use) the popup and load `url` (twUrl#draftTitle). */
21
+ export function openEditorPopup(url: string, label: string): void {
22
+ ensurePopup()
23
+ if (root === undefined || frame === undefined) return
24
+ if (titleEl !== undefined) titleEl.textContent = `TiddlyWiki 编辑器 · ${label}`
25
+ root.style.display = ''
26
+ // Same-base fragment navigation reloads nothing; a changed base (restart on a
27
+ // new port) reloads the app and TW still opens the draft from the hash.
28
+ frame.src = url
29
+ }
30
+
31
+ /** Remove the popup DOM entirely (plugin dispose). */
32
+ export function disposeEditorPopup(): void {
33
+ themeSyncDispose?.()
34
+ themeSyncDispose = undefined
35
+ root?.remove()
36
+ root = undefined
37
+ frame = undefined
38
+ titleEl = undefined
39
+ }
40
+
41
+ function ensurePopup(): void {
42
+ if (root !== undefined && frame !== undefined) return
43
+
44
+ root = document.createElement('div')
45
+ root.className = 'dsh-tw-editor-popup'
46
+ root.style.display = 'none'
47
+
48
+ const bar = document.createElement('div')
49
+ bar.className = 'dsh-tw-editor-bar'
50
+ titleEl = document.createElement('span')
51
+ titleEl.className = 'dsh-tw-editor-title'
52
+ titleEl.textContent = 'TiddlyWiki 编辑器'
53
+ const close = document.createElement('button')
54
+ close.type = 'button'
55
+ close.className = 'dsh-tw-editor-close'
56
+ close.textContent = '✕'
57
+ close.title = '关闭'
58
+ bar.append(titleEl, close)
59
+
60
+ frame = document.createElement('iframe')
61
+ frame.className = 'dsh-tw-editor-frame'
62
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups')
63
+ frame.title = 'TiddlyWiki 编辑器'
64
+ // Same-origin sandbox keeps $tw reachable; let the popup follow DSH theme.
65
+ themeSyncDispose = attachThemeSync(frame)
66
+
67
+ const resize = document.createElement('div')
68
+ resize.className = 'dsh-tw-editor-resize'
69
+ resize.title = '拖拽调整大小'
70
+
71
+ root.append(bar, frame, resize)
72
+ document.body.append(root)
73
+
74
+ close.addEventListener('click', () => {
75
+ if (root !== undefined) root.style.display = 'none'
76
+ })
77
+
78
+ // Drag by the title bar (un-center by setting explicit left/top + margin 0).
79
+ bar.addEventListener('mousedown', (event) => {
80
+ if (event.button !== 0 || root === undefined) return
81
+ event.preventDefault()
82
+ const rect = root.getBoundingClientRect()
83
+ const startX = event.clientX
84
+ const startY = event.clientY
85
+ const baseLeft = rect.left
86
+ const baseTop = rect.top
87
+ const onMove = (ev: MouseEvent): void => {
88
+ if (root === undefined) return
89
+ root.style.left = `${baseLeft + ev.clientX - startX}px`
90
+ root.style.top = `${baseTop + ev.clientY - startY}px`
91
+ root.style.margin = '0'
92
+ root.style.right = 'auto'
93
+ root.style.bottom = 'auto'
94
+ }
95
+ const onUp = (): void => {
96
+ window.removeEventListener('mousemove', onMove)
97
+ window.removeEventListener('mouseup', onUp)
98
+ }
99
+ window.addEventListener('mousemove', onMove)
100
+ window.addEventListener('mouseup', onUp)
101
+ })
102
+
103
+ // Resize from the bottom-right corner.
104
+ resize.addEventListener('mousedown', (event) => {
105
+ if (event.button !== 0 || root === undefined) return
106
+ event.preventDefault()
107
+ event.stopPropagation()
108
+ const rect = root.getBoundingClientRect()
109
+ const startX = event.clientX
110
+ const startY = event.clientY
111
+ const baseW = rect.width
112
+ const baseH = rect.height
113
+ const onMove = (ev: MouseEvent): void => {
114
+ if (root === undefined) return
115
+ root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
116
+ root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
117
+ root.style.right = 'auto'
118
+ root.style.bottom = 'auto'
119
+ }
120
+ const onUp = (): void => {
121
+ window.removeEventListener('mousemove', onMove)
122
+ window.removeEventListener('mouseup', onUp)
123
+ }
124
+ window.addEventListener('mousemove', onMove)
125
+ window.addEventListener('mouseup', onUp)
126
+ })
127
+ }
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import type { PanelState } from './state.ts'
24
24
  import { ENTRY_SELECTOR } from './sidebar-entry.ts'
25
+ import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
25
26
 
26
27
  export const PANEL_VIEW_SELECTOR = '[data-dsh-tw-view]'
27
28
 
@@ -84,7 +85,7 @@ interface StatusPayload {
84
85
  wikiPath?: string
85
86
  error?: string
86
87
  note?: { tag?: string }
87
- ui?: { showPanelStatus?: boolean }
88
+ ui?: { showPanelStatus?: boolean; followDshTheme?: boolean; darkPalette?: string }
88
89
  }
89
90
 
90
91
  function conversationColumn(): HTMLElement | undefined {
@@ -122,6 +123,7 @@ export function mountPanel(state: PanelState): () => void {
122
123
  let errorArea: HTMLDivElement | undefined
123
124
  let refreshTimer: number | undefined
124
125
  let refreshAttempts = 0
126
+ let themeSyncDispose: (() => void) | undefined
125
127
 
126
128
  const build = (): HTMLDivElement => {
127
129
  const view = document.createElement('div')
@@ -136,6 +138,9 @@ export function mountPanel(state: PanelState): () => void {
136
138
  iframe.title = 'TiddlyWiki'
137
139
  iframe.hidden = true
138
140
  frameArea.append(iframe)
141
+ // Embedded TW follows the DSH light/dark theme (non-persisting palette
142
+ // swap inside the same-origin iframe; re-applied on load + theme change).
143
+ themeSyncDispose = attachThemeSync(iframe)
139
144
 
140
145
  errorArea = document.createElement('div')
141
146
  errorArea.className = 'dsh-tw-panel-error'
@@ -239,6 +244,11 @@ export function mountPanel(state: PanelState): () => void {
239
244
  }
240
245
  if (payload.status === 'running') {
241
246
  refreshAttempts = 0
247
+ // Keep the embedded TW's theme adaption in step with the settings page.
248
+ setThemeSyncConfig({
249
+ enabled: payload.ui?.followDshTheme !== false,
250
+ darkPalette: payload.ui?.darkPalette,
251
+ })
242
252
  // Same-origin proxy URL: build from the page's own origin so it works no
243
253
  // matter which host/domain the user reached DSH on. Fall back to the
244
254
  // legacy loopback `url` for older servers that do not send twProxy.
@@ -330,6 +340,7 @@ export function mountPanel(state: PanelState): () => void {
330
340
  document.removeEventListener('dsh-tw-panel-reload', onReloadRequest)
331
341
  waitObserver.disconnect()
332
342
  unsubscribe()
343
+ themeSyncDispose?.()
333
344
  document.documentElement.removeAttribute(ACTIVE_ATTR)
334
345
  container?.remove()
335
346
  }
@@ -213,6 +213,8 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
213
213
  checkField('ui.showQuickNote', '显示「知识库」按钮里的「快速笔记」入口', ui.showQuickNote !== false)
214
214
  checkField('ui.showPanelStatus', '显示「知识库」按钮里的 TW 面板/重载入口与状态行', ui.showPanelStatus !== false)
215
215
  checkField('ui.showSyncButton', '显示「知识库」按钮里的「同步」入口与 git 状态点', ui.showSyncButton !== false)
216
+ checkField('ui.followDshTheme', '嵌入式 TW 跟随 DSH 深浅主题(暗色时自动切深色 palette,不写回 wiki)', ui.followDshTheme !== false)
217
+ textField('ui.darkPalette', '暗色时 TW palette(tiddler 标题)', typeof ui.darkPalette === 'string' && ui.darkPalette.length > 0 ? ui.darkPalette : '$:/palettes/CupertinoDark')
216
218
  // 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
217
219
 
218
220
  const save = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '保存配置')
@@ -77,7 +77,9 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
77
77
  .dsh-tw-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
78
78
  .dsh-tw-status-dot[data-state="failed"], .dsh-tw-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
79
79
 
80
- .dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: #fff; }
80
+ /* The iframe background follows the DSH theme so a blank/preload frame never
81
+ flashes pure white in dark mode (TW paints its own palette once loaded). */
82
+ .dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: var(--dsw-alias-bg-layer-1, #fff); }
81
83
  /* [hidden] must beat the author display rules above (UA hidden is overridden). */
82
84
  .dsh-tw-panel-frame[hidden],
83
85
  .dsh-tw-panel-error[hidden] { display: none !important; }
@@ -257,7 +259,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
257
259
  color: var(--dsw-alias-label-dimmed, #888); padding: 5px 8px; border-radius: 6px;
258
260
  }
259
261
  .dsh-tw-editor-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
260
- .dsh-tw-editor-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: #fff; }
262
+ .dsh-tw-editor-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: var(--dsw-alias-bg-layer-1, #fff); }
261
263
  .dsh-tw-editor-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; }
262
264
  .dsh-tw-note-toggle {
263
265
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Adaptive DSH theme for the embedded TiddlyWiki iframes (design doc §12).
3
+ *
4
+ * Both the center-column panel and the quick-note editor popup embed the
5
+ * SAME-ORIGIN TW proxy, so this module can reach the TW runtime directly
6
+ * (`iframe.contentWindow.$tw`) and drive its native theming.
7
+ *
8
+ * How TW theming works
9
+ * --------------------
10
+ * TW 5 renders its colours through the ACTIVE palette: `$:/palette` holds the
11
+ * title of a palette tiddler (`$:/palettes/…`), and every `<<colour x>>` in
12
+ * the theme stylesheet resolves against that tiddler at render time. TW
13
+ * re-renders all stylesheets live on any tiddler change, so flipping
14
+ * `$:/palette` re-themes the whole embedded UI instantly — the exact mechanism
15
+ * TW's own palette switcher uses. Overriding the rendered CSS variables is a
16
+ * dead end for the default Vanilla theme because its base stylesheet bakes the
17
+ * palette values in as literals; switching the palette is the supported path.
18
+ *
19
+ * DSH theme detection
20
+ * -------------------
21
+ * The DSH shell's ThemePresenter projects the resolved theme onto the page:
22
+ * `data-ds-dark-theme` on <body> (dark) plus an inline `color-scheme` on
23
+ * <html> that is always "light"|"dark" (the "system" preference is resolved
24
+ * upstream). Those two signals are authoritative; `prefers-color-scheme` is
25
+ * only a fallback when neither is present (no DSH theming projected).
26
+ *
27
+ * Non-persistence
28
+ * ---------------
29
+ * `$:/palette` IS included in TW's default sync filter, so a naive write would
30
+ * be PUT back to the TiddlyWeb server and land in the wiki's git history on
31
+ * every theme toggle. We suppress that: the syncer's dirty check is
32
+ * `getChangeCount(title) > tiddlerInfo[title].changeCount`, and the change
33
+ * event that would drive the save is dispatched on a microtask — so after
34
+ * writing we synchronously re-align the recorded changeCount and the syncer
35
+ * sees the tiddler as already clean. The flip stays in-memory only; the wiki's
36
+ * stored palette and the user's choice are never modified.
37
+ *
38
+ * @module dsh-tiddlywiki/client/theme-sync
39
+ */
40
+
41
+ /** Default dark palette used when DSH is dark (overridable in settings). */
42
+ export const DARK_PALETTE_DEFAULT = '$:/palettes/CupertinoDark'
43
+ /** Fallback light palette when no user palette was ever captured. */
44
+ export const LIGHT_PALETTE_FALLBACK = '$:/palettes/Vanilla'
45
+
46
+ /** DSH's dark-mode marker (set/removed on <body> by dsh-client-ui-layout). */
47
+ const DARK_ATTR = 'data-ds-dark-theme'
48
+ /** The TW tiddler holding the active palette tiddler title. */
49
+ const PALETTE_TIDDLER = '$:/palette'
50
+
51
+ /** Minimal structural face over TW's runtime globals inside the iframe. */
52
+ interface TwRuntime {
53
+ $tw?: {
54
+ wiki: {
55
+ getTiddlerText(title: string): string | undefined
56
+ getTiddler(title: string): { fields?: Record<string, unknown> } | undefined
57
+ /** TW 5.4.1 has no setTiddlerText; setText(title,'text',…) routes through
58
+ * addTiddler and bumps changeCount + dispatches the change event. */
59
+ setText(title: string, field: string | undefined, index: string | undefined, value: string | undefined): void
60
+ getChangeCount(title: string): number
61
+ }
62
+ syncer?: {
63
+ tiddlerInfo?: Record<string, { changeCount?: number; revision?: unknown; timestampLastSaved?: Date }>
64
+ syncadaptor?: {
65
+ saveTiddler: (tiddler: { fields?: Record<string, unknown> }, callback: (err: unknown, adaptorInfo: unknown, revision: number) => void) => void
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ /** Appliers registered for live frames; config changes re-apply to all. */
72
+ const frameAppliers = new Set<() => void>()
73
+ /** Syncers already wrapped with the palette save guard (per iframe window). */
74
+ const guardedSyncers = new WeakSet<object>()
75
+ /** Remembered user palette (light) that we restore when leaving dark mode. */
76
+ let userLightPalette = ''
77
+ /** Whether the adaptive feature is on (settings `ui.followDshTheme`). */
78
+ let syncEnabled = true
79
+ /** Configured dark palette (settings `ui.darkPalette`). */
80
+ let darkPalette = DARK_PALETTE_DEFAULT
81
+
82
+ /**
83
+ * Resolve the DSH shell's current effective dark state. The body attribute
84
+ * and the inline root `color-scheme` projected by the ThemePresenter are
85
+ * authoritative; the OS media query only answers when DSH isn't projecting a
86
+ * theme snapshot (plain embed / non-DSH page).
87
+ */
88
+ function readDshDark(): boolean {
89
+ try {
90
+ const root = document.documentElement
91
+ const body = document.body
92
+ if (body !== null && body.hasAttribute(DARK_ATTR)) return true
93
+ if (root !== null) {
94
+ const scheme = root.style.getPropertyValue('color-scheme').trim().toLowerCase()
95
+ if (scheme === 'dark') return true
96
+ if (scheme === 'light') return false
97
+ }
98
+ } catch {
99
+ // DOM not ready or not present (e.g. smoke harness) → fall through.
100
+ }
101
+ try {
102
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
103
+ } catch {
104
+ return false
105
+ }
106
+ }
107
+
108
+ /** Active palette tiddler title (falls back to the light default). */
109
+ function activePaletteTitle($tw: NonNullable<TwRuntime['$tw']>): string {
110
+ try {
111
+ const title = $tw.wiki.getTiddlerText(PALETTE_TIDDLER)
112
+ return typeof title === 'string' && title.trim().length > 0 ? title.trim() : LIGHT_PALETTE_FALLBACK
113
+ } catch {
114
+ return LIGHT_PALETTE_FALLBACK
115
+ }
116
+ }
117
+
118
+ /** True when the active palette tiddler declares `color-scheme: dark`. */
119
+ function activePaletteIsDark($tw: NonNullable<TwRuntime['$tw']>): boolean {
120
+ try {
121
+ const fields = $tw.wiki.getTiddler(activePaletteTitle($tw))?.fields
122
+ return fields !== undefined && fields['color-scheme'] === 'dark'
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Mark `$:/palette` as clean in the syncer's records so a FORCED flip is never
130
+ * PUT back to the TiddlyWeb server. Runs synchronously right after the write,
131
+ * before the microtask-dispatched change event lets the syncer evaluate
132
+ * dirtiness; user-initiated palette changes (bumped afterwards) still sync.
133
+ */
134
+ function suppressPaletteSync($tw: NonNullable<TwRuntime['$tw']>): void {
135
+ try {
136
+ const syncer = $tw.syncer
137
+ if (syncer?.tiddlerInfo === undefined) return
138
+ const changeCount = $tw.wiki.getChangeCount(PALETTE_TIDDLER)
139
+ const info = syncer.tiddlerInfo[PALETTE_TIDDLER]
140
+ if (info !== undefined) info.changeCount = changeCount
141
+ else syncer.tiddlerInfo[PALETTE_TIDDLER] = { changeCount }
142
+ } catch {
143
+ // Best-effort; the rare worst case is a single persisted flip.
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Install a one-shot guard on this frame's syncadaptor: while a palette WE
149
+ * forced is active (`userLightPalette` captured), a PUT of `$:/palette` whose
150
+ * text is still our forced dark palette is swallowed instead of reaching the
151
+ * TiddlyWeb server. This makes the non-persistence guarantee deterministic —
152
+ * TW's boot-time sync (which may load `$:/palette` from the server and bump its
153
+ * changeCount AFTER our re-align) can otherwise queue a save of the forced
154
+ * palette during the first ~1s. Idempotent per syncer instance.
155
+ */
156
+ function installPaletteSaveGuard($tw: NonNullable<TwRuntime['$tw']>): void {
157
+ try {
158
+ const syncer = $tw.syncer
159
+ const adaptor = syncer?.syncadaptor
160
+ if (syncer === undefined || adaptor === undefined || guardedSyncers.has(syncer)) return
161
+ const origSave = adaptor.saveTiddler.bind(adaptor)
162
+ adaptor.saveTiddler = (tiddler, callback): void => {
163
+ const suppressed =
164
+ syncEnabled &&
165
+ userLightPalette.length > 0 &&
166
+ tiddler?.fields?.title === PALETTE_TIDDLER &&
167
+ tiddler?.fields?.text === darkPalette
168
+ if (suppressed) {
169
+ // Fake a successful save (revision 0 = unchanged on the server); the
170
+ // SaveTiddlerTask re-records tiddlerInfo with the current changeCount,
171
+ // so the syncer's accounting stays consistent and no task is re-created.
172
+ callback(null, {}, 0)
173
+ return
174
+ }
175
+ origSave(tiddler, callback)
176
+ }
177
+ guardedSyncers.add(syncer)
178
+ } catch {
179
+ // Guard is best-effort; the changeCount re-align below still covers steady state.
180
+ }
181
+ }
182
+
183
+ /** Write the active palette (no-op when unchanged) without persisting. */
184
+ function setPalette($tw: NonNullable<TwRuntime['$tw']>, target: string): void {
185
+ try {
186
+ if (activePaletteTitle($tw) === target) return
187
+ $tw.wiki.setText(PALETTE_TIDDLER, 'text', undefined, target)
188
+ suppressPaletteSync($tw)
189
+ } catch {
190
+ // TW may be mid-render; the next apply pass will retry.
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Drive `$:/palette` toward the DSH theme. In dark mode a light palette is
196
+ * swapped for `darkPalette` (remembering the user's palette first); in light
197
+ * mode a palette WE forced is restored, while a user-chosen dark palette is
198
+ * left alone.
199
+ */
200
+ function applyPalette($tw: NonNullable<TwRuntime['$tw']>, dark: boolean): void {
201
+ const current = activePaletteTitle($tw)
202
+ if (dark) {
203
+ if (activePaletteIsDark($tw)) return // user already on a dark palette
204
+ if (current !== darkPalette) userLightPalette = current // remember their light choice
205
+ setPalette($tw, darkPalette)
206
+ } else if (activePaletteIsDark($tw) && current === darkPalette && userLightPalette.length > 0) {
207
+ // We forced this dark palette (and captured the user's light choice before
208
+ // doing so) → restore it. A user who picked the dark palette themselves is
209
+ // never captured, so their choice is left alone.
210
+ setPalette($tw, userLightPalette)
211
+ }
212
+ }
213
+
214
+ /** Keep the iframe's native `color-scheme` in step with the ACTIVE palette. */
215
+ function syncColorScheme($tw: NonNullable<TwRuntime['$tw']>, frame: HTMLIFrameElement): void {
216
+ try {
217
+ const html = frame.contentDocument?.documentElement
218
+ if (html === undefined || html === null) return
219
+ const paletteIsDark = activePaletteIsDark($tw)
220
+ const target = paletteIsDark ? 'dark' : 'light'
221
+ if (html.style.getPropertyValue('color-scheme') !== target) html.style.setProperty('color-scheme', target)
222
+ } catch {
223
+ // Cross-frame / not-yet-loaded edge cases are retried on the next pass.
224
+ }
225
+ }
226
+
227
+ /** Apply the current DSH theme to one TW iframe (no-op until TW is ready). */
228
+ function applyToFrame(frame: HTMLIFrameElement): void {
229
+ const $tw = (frame.contentWindow as TwRuntime | null)?.$tw
230
+ if ($tw === undefined || $tw.wiki === undefined) return
231
+ installPaletteSaveGuard($tw)
232
+ if (syncEnabled) {
233
+ applyPalette($tw, readDshDark())
234
+ } else {
235
+ // Feature off → undo any palette we forced (leave the user's own choice).
236
+ applyPalette($tw, false)
237
+ }
238
+ syncColorScheme($tw, frame)
239
+ }
240
+
241
+ /**
242
+ * Configure the feature and re-apply to every live frame. `enabled` is the
243
+ * settings `ui.followDshTheme`; `darkPalette` the settings `ui.darkPalette`.
244
+ */
245
+ export function setThemeSyncConfig(opts: { enabled?: boolean; darkPalette?: string }): void {
246
+ const enabled = opts.enabled !== false
247
+ const palette =
248
+ typeof opts.darkPalette === 'string' && opts.darkPalette.trim().length > 0 ? opts.darkPalette.trim() : DARK_PALETTE_DEFAULT
249
+ const changed = enabled !== syncEnabled || palette !== darkPalette
250
+ syncEnabled = enabled
251
+ darkPalette = palette
252
+ if (changed) for (const apply of [...frameAppliers]) apply()
253
+ }
254
+
255
+ /**
256
+ * Make one TW iframe follow the DSH theme for as long as the returned
257
+ * disposer is not called. Re-applies on the iframe's `load` (covers TW
258
+ * restarts and proxy reloads), on DSH theme changes (body-attribute mutations
259
+ * and `prefers-color-scheme`), and once immediately for already-loaded frames.
260
+ */
261
+ export function attachThemeSync(frame: HTMLIFrameElement | null): () => void {
262
+ if (frame === null) return () => {}
263
+ const apply = (): void => applyToFrame(frame)
264
+ frameAppliers.add(apply)
265
+
266
+ const onLoad = (): void => apply()
267
+ frame.addEventListener('load', onLoad)
268
+
269
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
270
+ const onMedia = (): void => apply()
271
+ try {
272
+ media.addEventListener('change', onMedia)
273
+ } catch {
274
+ media.addListener?.(onMedia)
275
+ }
276
+
277
+ let observer: MutationObserver | undefined
278
+ try {
279
+ observer = new MutationObserver(() => apply())
280
+ if (document.body !== null) observer.observe(document.body, { attributes: true, attributeFilter: [DARK_ATTR] })
281
+ if (document.documentElement !== null) {
282
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
283
+ }
284
+ } catch {
285
+ observer = undefined
286
+ }
287
+
288
+ // TW's boot-time sync can load `$:/palette` from the server shortly after
289
+ // load (reverting our in-memory flip and bumping its changeCount), so also
290
+ // re-apply on a few short lags. Each pass is idempotent: it re-flips in
291
+ // memory if a boot load reverted us, and re-aligns the syncer's changeCount.
292
+ const timers = [150, 600, 1500, 3000].map((ms) => window.setTimeout(apply, ms))
293
+
294
+ // The frame may already be loaded when this is attached late.
295
+ apply()
296
+
297
+ return () => {
298
+ frameAppliers.delete(apply)
299
+ frame.removeEventListener('load', onLoad)
300
+ for (const id of timers) window.clearTimeout(id)
301
+ try {
302
+ media.removeEventListener('change', onMedia)
303
+ } catch {
304
+ media.removeListener?.(onMedia)
305
+ }
306
+ try {
307
+ observer?.disconnect()
308
+ } catch {
309
+ // Already disconnected.
310
+ }
311
+ }
312
+ }