dsh-tiddlywiki 0.16.19 → 0.16.21
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/LICENSE +21 -21
- package/README.md +8 -1
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +53 -26
- package/lib/client.js +53 -26
- package/lib/index.js +136 -25
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/endpoints.ts +10 -10
- package/src/client/index.ts +36 -0
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/panel.ts +4 -0
- package/src/client/rightbar-tab.ts +440 -0
- package/src/client/session-summary.ts +279 -279
- package/src/client/settings-page.ts +1 -0
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +27 -0
- package/src/client/theme-sync.ts +312 -312
- package/src/client/toast.ts +22 -22
- package/src/client/tool-views.ts +604 -604
- package/src/client/ui-config.ts +64 -61
- package/src/host/config.ts +7 -0
- package/src/host/http.ts +57 -57
- package/src/host/routes.ts +2 -0
- package/src/host/seed-home.ts +1 -1
- package/src/host/seed-render.ts +66 -66
- package/src/host/seed-send-to-agent.ts +63 -63
- package/src/host/tools.ts +40 -5
- package/src/host/tw-api.ts +108 -16
- package/src/host/wiki.ts +299 -299
- package/src/index.ts +9 -8
- package/src/sdk.ts +204 -204
package/src/client/styles.ts
CHANGED
|
@@ -69,6 +69,33 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
69
69
|
.dsh-tw-panel-error button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
70
70
|
.dsh-tw-panel-error code { font-size: 11px; opacity: .8; max-width: 80%; overflow-wrap: anywhere; }
|
|
71
71
|
|
|
72
|
+
/* ── right-sidebar tab (DSH new rightbar) ──────────────────────
|
|
73
|
+
The React body host fills the rightbar pane; the TW iframe lives inside
|
|
74
|
+
it and follows the DSH theme like the center-column panel. */
|
|
75
|
+
.dsh-tw-rightbar-tab {
|
|
76
|
+
position: relative; display: flex; flex-direction: column;
|
|
77
|
+
width: 100%; height: 100%; min-height: 0; box-sizing: border-box;
|
|
78
|
+
background: var(--dsw-alias-bg-layer-1, var(--dsw-bg, #fff));
|
|
79
|
+
}
|
|
80
|
+
.dsh-tw-rightbar-view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
81
|
+
.dsh-tw-rightbar-frame-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
82
|
+
.dsh-tw-rightbar-frame {
|
|
83
|
+
flex: 1; min-height: 0; border: 0; width: 100%; display: block;
|
|
84
|
+
background: var(--dsw-alias-bg-layer-1, #fff);
|
|
85
|
+
}
|
|
86
|
+
.dsh-tw-rightbar-frame[hidden],
|
|
87
|
+
.dsh-tw-rightbar-error[hidden] { display: none !important; }
|
|
88
|
+
.dsh-tw-rightbar-error {
|
|
89
|
+
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
90
|
+
gap: 10px; color: var(--dsw-alias-label-secondary, #666); font-size: 13px; text-align: center; padding: 20px;
|
|
91
|
+
}
|
|
92
|
+
.dsh-tw-rightbar-error button {
|
|
93
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25)); background: transparent;
|
|
94
|
+
color: inherit; font: inherit; padding: 6px 14px; border-radius: 8px; cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
.dsh-tw-rightbar-error button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
97
|
+
.dsh-tw-rightbar-error code { font-size: 11px; opacity: .8; max-width: 80%; overflow-wrap: anywhere; }
|
|
98
|
+
|
|
72
99
|
/* ── floating quick-note widget ────────────────────────────────
|
|
73
100
|
Positioned ABOVE the shutdown launcher FAB (fixed right:24 bottom:24,
|
|
74
101
|
z-index 900, 46px) and BELOW its confirm overlay (z-index 1000).
|
package/src/client/theme-sync.ts
CHANGED
|
@@ -1,312 +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
|
-
}
|
|
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
|
+
}
|