dsh-tiddlywiki 0.7.4 → 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/lib/client.js CHANGED
@@ -107,7 +107,9 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
107
107
  .dsh-tw-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
108
108
  .dsh-tw-status-dot[data-state="failed"], .dsh-tw-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
109
109
 
110
- .dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: #fff; }
110
+ /* The iframe background follows the DSH theme so a blank/preload frame never
111
+ flashes pure white in dark mode (TW paints its own palette once loaded). */
112
+ .dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: var(--dsw-alias-bg-layer-1, #fff); }
111
113
  /* [hidden] must beat the author display rules above (UA hidden is overridden). */
112
114
  .dsh-tw-panel-frame[hidden],
113
115
  .dsh-tw-panel-error[hidden] { display: none !important; }
@@ -287,7 +289,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
287
289
  color: var(--dsw-alias-label-dimmed, #888); padding: 5px 8px; border-radius: 6px;
288
290
  }
289
291
  .dsh-tw-editor-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
290
- .dsh-tw-editor-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: #fff; }
292
+ .dsh-tw-editor-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: var(--dsw-alias-bg-layer-1, #fff); }
291
293
  .dsh-tw-editor-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; }
292
294
  .dsh-tw-note-toggle {
293
295
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
@@ -643,6 +645,253 @@ function mountSidebarEntry(state) {
643
645
  };
644
646
  }
645
647
  //#endregion
648
+ //#region src/client/theme-sync.ts
649
+ /**
650
+ * Adaptive DSH theme for the embedded TiddlyWiki iframes (design doc §12).
651
+ *
652
+ * Both the center-column panel and the quick-note editor popup embed the
653
+ * SAME-ORIGIN TW proxy, so this module can reach the TW runtime directly
654
+ * (`iframe.contentWindow.$tw`) and drive its native theming.
655
+ *
656
+ * How TW theming works
657
+ * --------------------
658
+ * TW 5 renders its colours through the ACTIVE palette: `$:/palette` holds the
659
+ * title of a palette tiddler (`$:/palettes/…`), and every `<<colour x>>` in
660
+ * the theme stylesheet resolves against that tiddler at render time. TW
661
+ * re-renders all stylesheets live on any tiddler change, so flipping
662
+ * `$:/palette` re-themes the whole embedded UI instantly — the exact mechanism
663
+ * TW's own palette switcher uses. Overriding the rendered CSS variables is a
664
+ * dead end for the default Vanilla theme because its base stylesheet bakes the
665
+ * palette values in as literals; switching the palette is the supported path.
666
+ *
667
+ * DSH theme detection
668
+ * -------------------
669
+ * The DSH shell's ThemePresenter projects the resolved theme onto the page:
670
+ * `data-ds-dark-theme` on <body> (dark) plus an inline `color-scheme` on
671
+ * <html> that is always "light"|"dark" (the "system" preference is resolved
672
+ * upstream). Those two signals are authoritative; `prefers-color-scheme` is
673
+ * only a fallback when neither is present (no DSH theming projected).
674
+ *
675
+ * Non-persistence
676
+ * ---------------
677
+ * `$:/palette` IS included in TW's default sync filter, so a naive write would
678
+ * be PUT back to the TiddlyWeb server and land in the wiki's git history on
679
+ * every theme toggle. We suppress that: the syncer's dirty check is
680
+ * `getChangeCount(title) > tiddlerInfo[title].changeCount`, and the change
681
+ * event that would drive the save is dispatched on a microtask — so after
682
+ * writing we synchronously re-align the recorded changeCount and the syncer
683
+ * sees the tiddler as already clean. The flip stays in-memory only; the wiki's
684
+ * stored palette and the user's choice are never modified.
685
+ *
686
+ * @module dsh-tiddlywiki/client/theme-sync
687
+ */
688
+ /** Default dark palette used when DSH is dark (overridable in settings). */
689
+ const DARK_PALETTE_DEFAULT = "$:/palettes/CupertinoDark";
690
+ /** Fallback light palette when no user palette was ever captured. */
691
+ const LIGHT_PALETTE_FALLBACK = "$:/palettes/Vanilla";
692
+ /** DSH's dark-mode marker (set/removed on <body> by dsh-client-ui-layout). */
693
+ const DARK_ATTR = "data-ds-dark-theme";
694
+ /** The TW tiddler holding the active palette tiddler title. */
695
+ const PALETTE_TIDDLER = "$:/palette";
696
+ /** Appliers registered for live frames; config changes re-apply to all. */
697
+ const frameAppliers = /* @__PURE__ */ new Set();
698
+ /** Syncers already wrapped with the palette save guard (per iframe window). */
699
+ const guardedSyncers = /* @__PURE__ */ new WeakSet();
700
+ /** Remembered user palette (light) that we restore when leaving dark mode. */
701
+ let userLightPalette = "";
702
+ /** Whether the adaptive feature is on (settings `ui.followDshTheme`). */
703
+ let syncEnabled = true;
704
+ /** Configured dark palette (settings `ui.darkPalette`). */
705
+ let darkPalette = DARK_PALETTE_DEFAULT;
706
+ /**
707
+ * Resolve the DSH shell's current effective dark state. The body attribute
708
+ * and the inline root `color-scheme` projected by the ThemePresenter are
709
+ * authoritative; the OS media query only answers when DSH isn't projecting a
710
+ * theme snapshot (plain embed / non-DSH page).
711
+ */
712
+ function readDshDark() {
713
+ try {
714
+ const root = document.documentElement;
715
+ const body = document.body;
716
+ if (body !== null && body.hasAttribute(DARK_ATTR)) return true;
717
+ if (root !== null) {
718
+ const scheme = root.style.getPropertyValue("color-scheme").trim().toLowerCase();
719
+ if (scheme === "dark") return true;
720
+ if (scheme === "light") return false;
721
+ }
722
+ } catch {}
723
+ try {
724
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
725
+ } catch {
726
+ return false;
727
+ }
728
+ }
729
+ /** Active palette tiddler title (falls back to the light default). */
730
+ function activePaletteTitle($tw) {
731
+ try {
732
+ const title = $tw.wiki.getTiddlerText(PALETTE_TIDDLER);
733
+ return typeof title === "string" && title.trim().length > 0 ? title.trim() : LIGHT_PALETTE_FALLBACK;
734
+ } catch {
735
+ return LIGHT_PALETTE_FALLBACK;
736
+ }
737
+ }
738
+ /** True when the active palette tiddler declares `color-scheme: dark`. */
739
+ function activePaletteIsDark($tw) {
740
+ try {
741
+ const fields = $tw.wiki.getTiddler(activePaletteTitle($tw))?.fields;
742
+ return fields !== void 0 && fields["color-scheme"] === "dark";
743
+ } catch {
744
+ return false;
745
+ }
746
+ }
747
+ /**
748
+ * Mark `$:/palette` as clean in the syncer's records so a FORCED flip is never
749
+ * PUT back to the TiddlyWeb server. Runs synchronously right after the write,
750
+ * before the microtask-dispatched change event lets the syncer evaluate
751
+ * dirtiness; user-initiated palette changes (bumped afterwards) still sync.
752
+ */
753
+ function suppressPaletteSync($tw) {
754
+ try {
755
+ const syncer = $tw.syncer;
756
+ if (syncer?.tiddlerInfo === void 0) return;
757
+ const changeCount = $tw.wiki.getChangeCount(PALETTE_TIDDLER);
758
+ const info = syncer.tiddlerInfo[PALETTE_TIDDLER];
759
+ if (info !== void 0) info.changeCount = changeCount;
760
+ else syncer.tiddlerInfo[PALETTE_TIDDLER] = { changeCount };
761
+ } catch {}
762
+ }
763
+ /**
764
+ * Install a one-shot guard on this frame's syncadaptor: while a palette WE
765
+ * forced is active (`userLightPalette` captured), a PUT of `$:/palette` whose
766
+ * text is still our forced dark palette is swallowed instead of reaching the
767
+ * TiddlyWeb server. This makes the non-persistence guarantee deterministic —
768
+ * TW's boot-time sync (which may load `$:/palette` from the server and bump its
769
+ * changeCount AFTER our re-align) can otherwise queue a save of the forced
770
+ * palette during the first ~1s. Idempotent per syncer instance.
771
+ */
772
+ function installPaletteSaveGuard($tw) {
773
+ try {
774
+ const syncer = $tw.syncer;
775
+ const adaptor = syncer?.syncadaptor;
776
+ if (syncer === void 0 || adaptor === void 0 || guardedSyncers.has(syncer)) return;
777
+ const origSave = adaptor.saveTiddler.bind(adaptor);
778
+ adaptor.saveTiddler = (tiddler, callback) => {
779
+ if (syncEnabled && userLightPalette.length > 0 && tiddler?.fields?.title === PALETTE_TIDDLER && tiddler?.fields?.text === darkPalette) {
780
+ callback(null, {}, 0);
781
+ return;
782
+ }
783
+ origSave(tiddler, callback);
784
+ };
785
+ guardedSyncers.add(syncer);
786
+ } catch {}
787
+ }
788
+ /** Write the active palette (no-op when unchanged) without persisting. */
789
+ function setPalette($tw, target) {
790
+ try {
791
+ if (activePaletteTitle($tw) === target) return;
792
+ $tw.wiki.setText(PALETTE_TIDDLER, "text", void 0, target);
793
+ suppressPaletteSync($tw);
794
+ } catch {}
795
+ }
796
+ /**
797
+ * Drive `$:/palette` toward the DSH theme. In dark mode a light palette is
798
+ * swapped for `darkPalette` (remembering the user's palette first); in light
799
+ * mode a palette WE forced is restored, while a user-chosen dark palette is
800
+ * left alone.
801
+ */
802
+ function applyPalette($tw, dark) {
803
+ const current = activePaletteTitle($tw);
804
+ if (dark) {
805
+ if (activePaletteIsDark($tw)) return;
806
+ if (current !== darkPalette) userLightPalette = current;
807
+ setPalette($tw, darkPalette);
808
+ } else if (activePaletteIsDark($tw) && current === darkPalette && userLightPalette.length > 0) setPalette($tw, userLightPalette);
809
+ }
810
+ /** Keep the iframe's native `color-scheme` in step with the ACTIVE palette. */
811
+ function syncColorScheme($tw, frame) {
812
+ try {
813
+ const html = frame.contentDocument?.documentElement;
814
+ if (html === void 0 || html === null) return;
815
+ const target = activePaletteIsDark($tw) ? "dark" : "light";
816
+ if (html.style.getPropertyValue("color-scheme") !== target) html.style.setProperty("color-scheme", target);
817
+ } catch {}
818
+ }
819
+ /** Apply the current DSH theme to one TW iframe (no-op until TW is ready). */
820
+ function applyToFrame(frame) {
821
+ const $tw = frame.contentWindow?.$tw;
822
+ if ($tw === void 0 || $tw.wiki === void 0) return;
823
+ installPaletteSaveGuard($tw);
824
+ if (syncEnabled) applyPalette($tw, readDshDark());
825
+ else applyPalette($tw, false);
826
+ syncColorScheme($tw, frame);
827
+ }
828
+ /**
829
+ * Configure the feature and re-apply to every live frame. `enabled` is the
830
+ * settings `ui.followDshTheme`; `darkPalette` the settings `ui.darkPalette`.
831
+ */
832
+ function setThemeSyncConfig(opts) {
833
+ const enabled = opts.enabled !== false;
834
+ const palette = typeof opts.darkPalette === "string" && opts.darkPalette.trim().length > 0 ? opts.darkPalette.trim() : DARK_PALETTE_DEFAULT;
835
+ const changed = enabled !== syncEnabled || palette !== darkPalette;
836
+ syncEnabled = enabled;
837
+ darkPalette = palette;
838
+ if (changed) for (const apply of [...frameAppliers]) apply();
839
+ }
840
+ /**
841
+ * Make one TW iframe follow the DSH theme for as long as the returned
842
+ * disposer is not called. Re-applies on the iframe's `load` (covers TW
843
+ * restarts and proxy reloads), on DSH theme changes (body-attribute mutations
844
+ * and `prefers-color-scheme`), and once immediately for already-loaded frames.
845
+ */
846
+ function attachThemeSync(frame) {
847
+ if (frame === null) return () => {};
848
+ const apply = () => applyToFrame(frame);
849
+ frameAppliers.add(apply);
850
+ const onLoad = () => apply();
851
+ frame.addEventListener("load", onLoad);
852
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
853
+ const onMedia = () => apply();
854
+ try {
855
+ media.addEventListener("change", onMedia);
856
+ } catch {
857
+ media.addListener?.(onMedia);
858
+ }
859
+ let observer;
860
+ try {
861
+ observer = new MutationObserver(() => apply());
862
+ if (document.body !== null) observer.observe(document.body, {
863
+ attributes: true,
864
+ attributeFilter: [DARK_ATTR]
865
+ });
866
+ if (document.documentElement !== null) observer.observe(document.documentElement, {
867
+ attributes: true,
868
+ attributeFilter: ["style"]
869
+ });
870
+ } catch {
871
+ observer = void 0;
872
+ }
873
+ const timers = [
874
+ 150,
875
+ 600,
876
+ 1500,
877
+ 3e3
878
+ ].map((ms) => window.setTimeout(apply, ms));
879
+ apply();
880
+ return () => {
881
+ frameAppliers.delete(apply);
882
+ frame.removeEventListener("load", onLoad);
883
+ for (const id of timers) window.clearTimeout(id);
884
+ try {
885
+ media.removeEventListener("change", onMedia);
886
+ } catch {
887
+ media.removeListener?.(onMedia);
888
+ }
889
+ try {
890
+ observer?.disconnect();
891
+ } catch {}
892
+ };
893
+ }
894
+ //#endregion
646
895
  //#region src/client/panel.ts
647
896
  /**
648
897
  * Center-column targets, most-specific shell generation first. The official
@@ -730,6 +979,7 @@ function mountPanel(state) {
730
979
  let errorArea;
731
980
  let refreshTimer;
732
981
  let refreshAttempts = 0;
982
+ let themeSyncDispose;
733
983
  const build = () => {
734
984
  const view = document.createElement("div");
735
985
  view.dataset.dshTwView = "";
@@ -742,6 +992,7 @@ function mountPanel(state) {
742
992
  iframe.title = "TiddlyWiki";
743
993
  iframe.hidden = true;
744
994
  frameArea.append(iframe);
995
+ themeSyncDispose = attachThemeSync(iframe);
745
996
  errorArea = document.createElement("div");
746
997
  errorArea.className = "dsh-tw-panel-error";
747
998
  errorArea.hidden = true;
@@ -835,6 +1086,10 @@ function mountPanel(state) {
835
1086
  }
836
1087
  if (payload.status === "running") {
837
1088
  refreshAttempts = 0;
1089
+ setThemeSyncConfig({
1090
+ enabled: payload.ui?.followDshTheme !== false,
1091
+ darkPalette: payload.ui?.darkPalette
1092
+ });
838
1093
  if (typeof payload.twProxy === "string") showFrame(new URL(payload.twProxy, window.location.origin).href);
839
1094
  else if (typeof payload.url === "string") showFrame(payload.url);
840
1095
  else showError("服务未返回编辑器地址");
@@ -913,6 +1168,7 @@ function mountPanel(state) {
913
1168
  document.removeEventListener("dsh-tw-panel-reload", onReloadRequest);
914
1169
  waitObserver.disconnect();
915
1170
  unsubscribe();
1171
+ themeSyncDispose?.();
916
1172
  document.documentElement.removeAttribute(ACTIVE_ATTR);
917
1173
  container?.remove();
918
1174
  };
@@ -957,6 +1213,7 @@ function toast(message) {
957
1213
  let root;
958
1214
  let frame;
959
1215
  let titleEl;
1216
+ let themeSyncDispose;
960
1217
  /** Open (create on first use) the popup and load `url` (twUrl#draftTitle). */
961
1218
  function openEditorPopup(url, label) {
962
1219
  ensurePopup();
@@ -967,6 +1224,8 @@ function openEditorPopup(url, label) {
967
1224
  }
968
1225
  /** Remove the popup DOM entirely (plugin dispose). */
969
1226
  function disposeEditorPopup() {
1227
+ themeSyncDispose?.();
1228
+ themeSyncDispose = void 0;
970
1229
  root?.remove();
971
1230
  root = void 0;
972
1231
  frame = void 0;
@@ -992,6 +1251,7 @@ function ensurePopup() {
992
1251
  frame.className = "dsh-tw-editor-frame";
993
1252
  frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups");
994
1253
  frame.title = "TiddlyWiki 编辑器";
1254
+ themeSyncDispose = attachThemeSync(frame);
995
1255
  const resize = document.createElement("div");
996
1256
  resize.className = "dsh-tw-editor-resize";
997
1257
  resize.title = "拖拽调整大小";
@@ -27745,6 +28005,8 @@ function renderConfigSection(body, config, refresh) {
27745
28005
  checkField("ui.showQuickNote", "显示「知识库」按钮里的「快速笔记」入口", ui.showQuickNote !== false);
27746
28006
  checkField("ui.showPanelStatus", "显示「知识库」按钮里的 TW 面板/重载入口与状态行", ui.showPanelStatus !== false);
27747
28007
  checkField("ui.showSyncButton", "显示「知识库」按钮里的「同步」入口与 git 状态点", ui.showSyncButton !== false);
28008
+ checkField("ui.followDshTheme", "嵌入式 TW 跟随 DSH 深浅主题(暗色时自动切深色 palette,不写回 wiki)", ui.followDshTheme !== false);
28009
+ textField("ui.darkPalette", "暗色时 TW palette(tiddler 标题)", typeof ui.darkPalette === "string" && ui.darkPalette.length > 0 ? ui.darkPalette : "$:/palettes/CupertinoDark");
27748
28010
  const save = make("button", "dsh-tw-settings-btn dsh-tw-settings-primary", "保存配置");
27749
28011
  save.type = "button";
27750
28012
  save.addEventListener("click", () => {
package/lib/index.js CHANGED
@@ -1518,6 +1518,11 @@ function registerRoutes(ctx, deps) {
1518
1518
  //#region src/host/config.ts
1519
1519
  /** Config tiddler (JSON string) where the settings page stores overrides. */
1520
1520
  const CONFIG_TIDDLER = "$:/plugins/dsh-tiddlywiki/config";
1521
+ /**
1522
+ * Default dark palette the embedded TW switches to when DSH is dark
1523
+ * (mirrored in src/client/theme-sync.ts — the two bundles cannot share code).
1524
+ */
1525
+ const DARK_PALETTE_DEFAULT = "$:/palettes/CupertinoDark";
1521
1526
  function isPlainObject(value) {
1522
1527
  return typeof value === "object" && value !== null && !Array.isArray(value);
1523
1528
  }
@@ -3134,6 +3139,8 @@ const DEFAULTS = {
3134
3139
  showQuickNote: true,
3135
3140
  showPanelStatus: true,
3136
3141
  showSyncButton: true,
3142
+ followDshTheme: true,
3143
+ darkPalette: DARK_PALETTE_DEFAULT,
3137
3144
  sendToAgent: { enabled: true }
3138
3145
  },
3139
3146
  auth: {
@@ -3264,11 +3271,17 @@ function apply(ctx, rawConfig = {}) {
3264
3271
  const tag = eff().note?.tag;
3265
3272
  return typeof tag === "string" && tag.trim().length > 0 ? tag : config.note.tag;
3266
3273
  };
3267
- const effectiveUi = () => ({
3268
- showQuickNote: eff().ui?.showQuickNote !== false,
3269
- showPanelStatus: eff().ui?.showPanelStatus !== false,
3270
- showSyncButton: eff().ui?.showSyncButton !== false
3271
- });
3274
+ const effectiveUi = () => {
3275
+ const ui = eff().ui ?? {};
3276
+ const palette = typeof ui.darkPalette === "string" && ui.darkPalette.trim().length > 0 ? ui.darkPalette.trim() : DARK_PALETTE_DEFAULT;
3277
+ return {
3278
+ showQuickNote: ui.showQuickNote !== false,
3279
+ showPanelStatus: ui.showPanelStatus !== false,
3280
+ showSyncButton: ui.showSyncButton !== false,
3281
+ followDshTheme: ui.followDshTheme !== false,
3282
+ darkPalette: palette
3283
+ };
3284
+ };
3272
3285
  const disposers = [];
3273
3286
  const disposeAll = () => {
3274
3287
  for (const dispose of disposers.splice(0)) dispose();