dsh-tiddlywiki 0.2.1 → 0.3.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.
@@ -23,6 +23,7 @@ const STATE_ENDPOINT = '/dsh-tiddlywiki/admin/state'
23
23
  const INFO_ENDPOINT = '/dsh-tiddlywiki/admin/info'
24
24
  const CONFIG_ENDPOINT = '/dsh-tiddlywiki/admin/config'
25
25
  const RESTART_ENDPOINT = '/dsh-tiddlywiki/admin/restart'
26
+ const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
26
27
 
27
28
  interface CatalogEntry {
28
29
  name: string
@@ -119,6 +120,34 @@ function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promis
119
120
  state.git?.dirty === true ? '有未提交改动' : '',
120
121
  ].filter(Boolean).join(' · ')
121
122
  const label = make('span', 'dsh-tw-settings-muted', info)
123
+ const sync = make('button', 'dsh-tw-settings-btn', '同步')
124
+ sync.type = 'button'
125
+ sync.title = 'git 同步(pull → commit → push)'
126
+ sync.addEventListener('click', () => {
127
+ sync.disabled = true
128
+ sync.textContent = '同步中…'
129
+ void (async () => {
130
+ try {
131
+ const res = await fetch(SYNC_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
132
+ const payload = (await res.json().catch(() => null)) as { ok?: boolean; message?: string; error?: string; changed?: boolean; restarted?: boolean; restartError?: string } | null
133
+ if (!res.ok || payload?.ok !== true) toast(`同步失败:${payload?.error ?? payload?.message ?? `HTTP ${res.status}`}`)
134
+ else {
135
+ let detail = ''
136
+ if (payload.changed === true) {
137
+ detail += payload.restarted === true ? ',TW 已重启' : ',TW 未自动重启'
138
+ if (payload.restartError) detail += `(${payload.restartError})`
139
+ }
140
+ toast(`同步完成:${payload.message ?? 'OK'}${detail}`)
141
+ }
142
+ } catch (err) {
143
+ toast(`同步失败:${err instanceof Error ? err.message : String(err)}`)
144
+ } finally {
145
+ sync.disabled = false
146
+ sync.textContent = '同步'
147
+ void refresh()
148
+ }
149
+ })()
150
+ })
122
151
  const restart = make('button', 'dsh-tw-settings-btn', '重启 TW')
123
152
  restart.type = 'button'
124
153
  restart.addEventListener('click', () => {
@@ -137,7 +166,7 @@ function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promis
137
166
  }
138
167
  })()
139
168
  })
140
- row.append(chip, label, restart)
169
+ row.append(chip, label, sync, restart)
141
170
  }
142
171
 
143
172
  /** Config section: fields bound to effective config, changed-only save. */
@@ -183,6 +212,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
183
212
  textField('git.branch', 'git 分支', typeof git.branch === 'string' ? git.branch : 'main')
184
213
  checkField('ui.showQuickNote', '显示「快速笔记」悬浮按钮', ui.showQuickNote !== false)
185
214
  checkField('ui.showPanelStatus', '显示 TW 面板右下角「状态/重载」悬浮按钮', ui.showPanelStatus !== false)
215
+ checkField('ui.showSyncButton', '显示右下角「同步」悬浮按钮', ui.showSyncButton !== false)
186
216
  // 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
187
217
 
188
218
  const save = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '保存配置')
@@ -1,148 +1,148 @@
1
- /**
2
- * Sidebar entry injection — structure ported from dsh-taskboard's
3
- * sidebar-entry.ts (verified live in this shell): scope to the sidebar root,
4
- * find the New Session button, and insert the entry as a direct child of that
5
- * root next to the family block. A body-level MutationObserver self-heals
6
- * React re-renders; a slow timer covers shells that mount late without
7
- * further mutations. The row is plain DOM so it never disturbs the shell's
8
- * reconciliation.
9
- *
10
- * @module dsh-tiddlywiki/client/sidebar-entry
11
- */
12
- import type { PanelState } from './state.ts'
13
-
14
- /** Stable data attribute identifying this entry row. */
15
- export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
16
-
17
- /** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
18
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M6 6h4M6 8.5h2.5"/></svg>'
19
-
20
- /** Family entries from sibling plugins, kept in a stable relative order. */
21
- const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
22
-
23
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
24
- function sidebarRoot(): HTMLElement | undefined {
25
- const column = document.querySelector<HTMLElement>(
26
- '[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface',
27
- )
28
- if (column === null) return undefined
29
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
30
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
31
- }
32
-
33
- /** The New Session button inside the sidebar root. */
34
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
35
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
36
- if (nested !== null) return nested
37
- for (const child of root.children) {
38
- if (child instanceof HTMLButtonElement && !child.matches(ENTRY_SELECTOR)) return child
39
- }
40
- const byAria = root.querySelector<HTMLButtonElement>(
41
- 'button[aria-label="新建会话"], button[aria-label="New Session"], button[aria-label*="新会话"], button[aria-label*="new session" i]',
42
- )
43
- if (byAria !== null) return byAria
44
- const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button'))
45
- return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
46
- }
47
-
48
- /** Build the entry row (a detached button; insert once the shell is up). */
49
- function createEntry(state: PanelState): HTMLButtonElement {
50
- const entry = document.createElement('button')
51
- entry.type = 'button'
52
- entry.dataset.dshTwEntry = ''
53
- entry.className = 'dsh-tw-entry'
54
- entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
55
- entry.innerHTML = `<span class="dsh-tw-entry-icon">${ICON}</span><span class="dsh-tw-entry-label">TiddlyWiki</span>`
56
- entry.addEventListener('click', () => { state.toggle() })
57
- return entry
58
- }
59
-
60
- /** Re-insert the entry before the whole family block (stable ordering). */
61
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
62
- const button = newSessionButton(root)
63
- if (button === undefined) return false
64
- if (entry.parentElement !== root) {
65
- const row = button.closest('[class*="logoRow"]')
66
- const base = (row !== null && row.parentElement === root) ? row : button
67
- const family = Array.from(root.children).filter(
68
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_SELECTOR),
69
- )
70
- const anchor = family.length > 0 ? (family[0] ?? null) : (base.nextElementSibling ?? null)
71
- root.insertBefore(entry, anchor)
72
- }
73
- return true
74
- }
75
-
76
- /** Debug counters (window.__twDebug) — evidence if the entry fails to appear. */
77
- interface TwDebug { attempts: number; found: boolean; placed: boolean }
78
-
79
- /**
80
- * Mount the sidebar entry, waiting for the shell and self-healing on later
81
- * re-renders.
82
- * @param state - the shared panel state the entry toggles.
83
- * @returns disposer removing the entry and its observers.
84
- */
85
- export function mountSidebarEntry(state: PanelState): () => void {
86
- const entry = createEntry(state)
87
- const debug: TwDebug = { attempts: 0, found: false, placed: false }
88
- const host = globalThis.location?.hostname
89
- if (host === 'localhost' || host === '127.0.0.1') {
90
- ;(window as unknown as { __twDebug?: TwDebug }).__twDebug = debug
91
- }
92
- let root: HTMLElement | undefined
93
- let placed = false
94
-
95
- const tryPlace = (): void => {
96
- debug.attempts++
97
- if (root !== undefined && !root.isConnected) {
98
- rootObserver.disconnect()
99
- root = undefined
100
- placed = false
101
- }
102
- if (placed) {
103
- if (document.body.contains(entry)) return
104
- rootObserver.disconnect()
105
- root = undefined
106
- placed = false
107
- }
108
- root ??= sidebarRoot()
109
- if (root === undefined) return
110
- debug.found = newSessionButton(root) !== undefined
111
- placed = placeEntry(root, entry)
112
- debug.placed = placed
113
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
114
- }
115
-
116
- // Body-level watcher as the whole-rebuild fallback.
117
- const waitObserver = new MutationObserver(() => { tryPlace() })
118
- waitObserver.observe(document.body, { childList: true, subtree: true })
119
-
120
- // Self-heal: re-insert in the same frame when a re-render displaces the row.
121
- const rootObserver = new MutationObserver(() => {
122
- if (root === undefined || !root.isConnected) {
123
- placed = false
124
- tryPlace()
125
- return
126
- }
127
- if (!root.contains(entry)) placed = placeEntry(root, entry)
128
- })
129
-
130
- // Belt-and-braces: a late shell mount with no further mutations still heals.
131
- const retry = setInterval(() => { tryPlace() }, 2_000)
132
-
133
- const syncActive = (): void => {
134
- if (state.isOpen()) entry.dataset.active = 'true'
135
- else delete entry.dataset.active
136
- }
137
- const unsubscribe = state.subscribe(syncActive)
138
- syncActive()
139
- tryPlace()
140
-
141
- return () => {
142
- clearInterval(retry)
143
- waitObserver.disconnect()
144
- rootObserver.disconnect()
145
- unsubscribe()
146
- entry.remove()
147
- }
148
- }
1
+ /**
2
+ * Sidebar entry injection — structure ported from dsh-taskboard's
3
+ * sidebar-entry.ts (verified live in this shell): scope to the sidebar root,
4
+ * find the New Session button, and insert the entry as a direct child of that
5
+ * root next to the family block. A body-level MutationObserver self-heals
6
+ * React re-renders; a slow timer covers shells that mount late without
7
+ * further mutations. The row is plain DOM so it never disturbs the shell's
8
+ * reconciliation.
9
+ *
10
+ * @module dsh-tiddlywiki/client/sidebar-entry
11
+ */
12
+ import type { PanelState } from './state.ts'
13
+
14
+ /** Stable data attribute identifying this entry row. */
15
+ export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
16
+
17
+ /** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
18
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M6 6h4M6 8.5h2.5"/></svg>'
19
+
20
+ /** Family entries from sibling plugins, kept in a stable relative order. */
21
+ const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
22
+
23
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
24
+ function sidebarRoot(): HTMLElement | undefined {
25
+ const column = document.querySelector<HTMLElement>(
26
+ '[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface',
27
+ )
28
+ if (column === null) return undefined
29
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
30
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
31
+ }
32
+
33
+ /** The New Session button inside the sidebar root. */
34
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
35
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
36
+ if (nested !== null) return nested
37
+ for (const child of root.children) {
38
+ if (child instanceof HTMLButtonElement && !child.matches(ENTRY_SELECTOR)) return child
39
+ }
40
+ const byAria = root.querySelector<HTMLButtonElement>(
41
+ 'button[aria-label="新建会话"], button[aria-label="New Session"], button[aria-label*="新会话"], button[aria-label*="new session" i]',
42
+ )
43
+ if (byAria !== null) return byAria
44
+ const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button'))
45
+ return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
46
+ }
47
+
48
+ /** Build the entry row (a detached button; insert once the shell is up). */
49
+ function createEntry(state: PanelState): HTMLButtonElement {
50
+ const entry = document.createElement('button')
51
+ entry.type = 'button'
52
+ entry.dataset.dshTwEntry = ''
53
+ entry.className = 'dsh-tw-entry'
54
+ entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
55
+ entry.innerHTML = `<span class="dsh-tw-entry-icon">${ICON}</span><span class="dsh-tw-entry-label">TiddlyWiki</span>`
56
+ entry.addEventListener('click', () => { state.toggle() })
57
+ return entry
58
+ }
59
+
60
+ /** Re-insert the entry before the whole family block (stable ordering). */
61
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
62
+ const button = newSessionButton(root)
63
+ if (button === undefined) return false
64
+ if (entry.parentElement !== root) {
65
+ const row = button.closest('[class*="logoRow"]')
66
+ const base = (row !== null && row.parentElement === root) ? row : button
67
+ const family = Array.from(root.children).filter(
68
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_SELECTOR),
69
+ )
70
+ const anchor = family.length > 0 ? (family[0] ?? null) : (base.nextElementSibling ?? null)
71
+ root.insertBefore(entry, anchor)
72
+ }
73
+ return true
74
+ }
75
+
76
+ /** Debug counters (window.__twDebug) — evidence if the entry fails to appear. */
77
+ interface TwDebug { attempts: number; found: boolean; placed: boolean }
78
+
79
+ /**
80
+ * Mount the sidebar entry, waiting for the shell and self-healing on later
81
+ * re-renders.
82
+ * @param state - the shared panel state the entry toggles.
83
+ * @returns disposer removing the entry and its observers.
84
+ */
85
+ export function mountSidebarEntry(state: PanelState): () => void {
86
+ const entry = createEntry(state)
87
+ const debug: TwDebug = { attempts: 0, found: false, placed: false }
88
+ const host = globalThis.location?.hostname
89
+ if (host === 'localhost' || host === '127.0.0.1') {
90
+ ;(window as unknown as { __twDebug?: TwDebug }).__twDebug = debug
91
+ }
92
+ let root: HTMLElement | undefined
93
+ let placed = false
94
+
95
+ const tryPlace = (): void => {
96
+ debug.attempts++
97
+ if (root !== undefined && !root.isConnected) {
98
+ rootObserver.disconnect()
99
+ root = undefined
100
+ placed = false
101
+ }
102
+ if (placed) {
103
+ if (document.body.contains(entry)) return
104
+ rootObserver.disconnect()
105
+ root = undefined
106
+ placed = false
107
+ }
108
+ root ??= sidebarRoot()
109
+ if (root === undefined) return
110
+ debug.found = newSessionButton(root) !== undefined
111
+ placed = placeEntry(root, entry)
112
+ debug.placed = placed
113
+ if (placed) rootObserver.observe(root, { childList: true, subtree: true })
114
+ }
115
+
116
+ // Body-level watcher as the whole-rebuild fallback.
117
+ const waitObserver = new MutationObserver(() => { tryPlace() })
118
+ waitObserver.observe(document.body, { childList: true, subtree: true })
119
+
120
+ // Self-heal: re-insert in the same frame when a re-render displaces the row.
121
+ const rootObserver = new MutationObserver(() => {
122
+ if (root === undefined || !root.isConnected) {
123
+ placed = false
124
+ tryPlace()
125
+ return
126
+ }
127
+ if (!root.contains(entry)) placed = placeEntry(root, entry)
128
+ })
129
+
130
+ // Belt-and-braces: a late shell mount with no further mutations still heals.
131
+ const retry = setInterval(() => { tryPlace() }, 2_000)
132
+
133
+ const syncActive = (): void => {
134
+ if (state.isOpen()) entry.dataset.active = 'true'
135
+ else delete entry.dataset.active
136
+ }
137
+ const unsubscribe = state.subscribe(syncActive)
138
+ syncActive()
139
+ tryPlace()
140
+
141
+ return () => {
142
+ clearInterval(retry)
143
+ waitObserver.disconnect()
144
+ rootObserver.disconnect()
145
+ unsubscribe()
146
+ entry.remove()
147
+ }
148
+ }
@@ -1,39 +1,39 @@
1
- /**
2
- * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
- *
4
- * @module dsh-tiddlywiki/client/state
5
- */
6
-
7
- export type PanelListener = (open: boolean) => void
8
-
9
- export class PanelState {
10
- private open = false
11
- private readonly listeners = new Set<PanelListener>()
12
-
13
- isOpen(): boolean {
14
- return this.open
15
- }
16
-
17
- toggle(): void {
18
- this.set(!this.open)
19
- }
20
-
21
- openPanel(): void {
22
- this.set(true)
23
- }
24
-
25
- closePanel(): void {
26
- this.set(false)
27
- }
28
-
29
- set(value: boolean): void {
30
- if (this.open === value) return
31
- this.open = value
32
- for (const listener of [...this.listeners]) listener(value)
33
- }
34
-
35
- subscribe(listener: PanelListener): () => void {
36
- this.listeners.add(listener)
37
- return () => { this.listeners.delete(listener) }
38
- }
39
- }
1
+ /**
2
+ * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
+ *
4
+ * @module dsh-tiddlywiki/client/state
5
+ */
6
+
7
+ export type PanelListener = (open: boolean) => void
8
+
9
+ export class PanelState {
10
+ private open = false
11
+ private readonly listeners = new Set<PanelListener>()
12
+
13
+ isOpen(): boolean {
14
+ return this.open
15
+ }
16
+
17
+ toggle(): void {
18
+ this.set(!this.open)
19
+ }
20
+
21
+ openPanel(): void {
22
+ this.set(true)
23
+ }
24
+
25
+ closePanel(): void {
26
+ this.set(false)
27
+ }
28
+
29
+ set(value: boolean): void {
30
+ if (this.open === value) return
31
+ this.open = value
32
+ for (const listener of [...this.listeners]) listener(value)
33
+ }
34
+
35
+ subscribe(listener: PanelListener): () => void {
36
+ this.listeners.add(listener)
37
+ return () => { this.listeners.delete(listener) }
38
+ }
39
+ }
@@ -58,9 +58,9 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
58
58
  setting controls whether they mount at all. */
59
59
  .dsh-tw-panel-status {
60
60
  /* 状态/重载悬浮按钮:右对齐到与快速笔记按钮同一条竖线(right:24),
61
- 垂直排在快速笔记按钮(bottom:88,高约 38px)上方;
61
+ 垂直排在「同步」按钮(bottom:140)上方;
62
62
  两个按钮竖直依次排列(状态在上、重载在下),与其他插件按钮列对齐、不堆叠。 */
63
- position: fixed; right: 24px; bottom: 140px; z-index: 950;
63
+ position: fixed; right: 24px; bottom: 192px; z-index: 950;
64
64
  display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
65
65
  }
66
66
  .dsh-tw-panel-status-btn {
@@ -131,14 +131,74 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
131
131
  outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
132
132
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
133
133
  }
134
- .dsh-tw-note-text {
134
+ .dsh-tw-note-text::placeholder { color: var(--dsw-alias-label-dimmed, #999); }
135
+ /* ── Markdown editor (highlighted-textarea overlay) ────────────────
136
+ The <pre> highlight layer and the transparent <textarea> share identical
137
+ metrics (border/padding/font/line-height/white-space) so lines align 1:1. */
138
+ .dsh-tw-note-editor { position: relative; min-width: 0; }
139
+ .dsh-tw-note-editor pre,
140
+ .dsh-tw-note-editor textarea {
141
+ margin: 0; box-sizing: border-box; width: 100%;
135
142
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18)); border-radius: 8px;
136
- padding: 8px 9px; font: inherit; font-size: 13px; min-height: 120px; resize: vertical;
137
- background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, inherit);
143
+ padding: 8px 9px; font: inherit; font-size: 13px; line-height: 1.5; min-height: 120px;
144
+ white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; tab-size: 4;
138
145
  transition: border-color 120ms ease, box-shadow 120ms ease;
139
146
  }
147
+ .dsh-tw-note-editor .dsh-tw-note-hl {
148
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 0;
149
+ background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
150
+ pointer-events: none; overflow: hidden;
151
+ }
152
+ .dsh-tw-note-editor .dsh-tw-note-text {
153
+ position: relative; z-index: 1; resize: vertical;
154
+ background: transparent; color: transparent; caret-color: var(--dsw-alias-label-primary, #222);
155
+ overflow: auto;
156
+ }
157
+ .dsh-tw-note-editor .dsh-tw-note-text:focus { outline: none; }
158
+ .dsh-tw-note-drop .dsh-tw-note-text,
159
+ .dsh-tw-note-drop .dsh-tw-note-hl {
160
+ border-color: var(--dsw-alias-brand-primary, #3e63dd);
161
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
162
+ }
163
+ /* ── markdown highlight tokens (colors only — never size/height, so the
164
+ overlay keeps line-for-line alignment with the invisible textarea) ── */
165
+ .dsh-tw-note-hl .md-heading { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
166
+ .dsh-tw-note-hl .md-hash { font-weight: 700; color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent); }
167
+ .dsh-tw-note-hl .md-code,
168
+ .dsh-tw-note-hl .md-code-block,
169
+ .dsh-tw-note-hl .md-fence {
170
+ font-family: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
171
+ }
172
+ .dsh-tw-note-hl .md-code {
173
+ background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 14%, transparent);
174
+ border-radius: 4px; padding: 0 3px;
175
+ }
176
+ .dsh-tw-note-hl .md-code-block,
177
+ .dsh-tw-note-hl .md-fence {
178
+ display: block; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
179
+ }
180
+ .dsh-tw-note-hl .md-bold { font-weight: 700; }
181
+ .dsh-tw-note-hl .md-italic { font-style: italic; }
182
+ .dsh-tw-note-hl .md-strike { text-decoration: line-through; opacity: .75; }
183
+ .dsh-tw-note-hl .md-link { color: var(--dsw-alias-brand-primary, #3e63dd); text-decoration: underline; }
184
+ .dsh-tw-note-hl .md-url { color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 70%, #999); text-decoration: underline dotted; }
185
+ .dsh-tw-note-hl .md-image { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
186
+ .dsh-tw-note-hl .md-bullet { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
187
+ .dsh-tw-note-hl .md-number { font-weight: 700; color: var(--dsw-alias-state-warning-primary, #d9822b); }
188
+ .dsh-tw-note-hl .md-quote { font-style: italic; color: var(--dsw-alias-label-secondary, #888); }
189
+ .dsh-tw-note-hl .md-hr { color: var(--dsw-alias-label-dimmed, #999); text-decoration: line-through; }
190
+ .dsh-tw-note-hl .md-task { color: var(--dsw-alias-label-secondary, #888); }
191
+ .dsh-tw-note-hl .md-task-checked { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
140
192
  .dsh-tw-note-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
193
+ .dsh-tw-note-foot-left, .dsh-tw-note-foot-right { display: flex; align-items: center; gap: 8px; }
141
194
  .dsh-tw-note-hint { font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
195
+ .dsh-tw-note-upload {
196
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
197
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
198
+ font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
199
+ transition: filter 120ms ease;
200
+ }
201
+ .dsh-tw-note-upload:hover { filter: brightness(.96); }
142
202
  .dsh-tw-note-save {
143
203
  border: 1px solid transparent; background: var(--dsw-alias-brand-primary, #3e63dd); color: #fff;
144
204
  font: inherit; font-size: 12px; padding: 6px 16px; border-radius: 8px; cursor: pointer;
@@ -216,6 +276,29 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
216
276
  }
217
277
  .dsh-tw-note-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
218
278
 
279
+ /* ── floating sync button (bottom-right) ───────────────────────
280
+ Pill FAB between the quick-note toggle (bottom:88) and the TW panel
281
+ status/reload floaters (bottom:192); dot reflects git state. */
282
+ .dsh-tw-sync {
283
+ position: fixed; right: 24px; bottom: 140px; z-index: 950;
284
+ display: inline-flex; align-items: center; gap: 7px;
285
+ padding: 9px 14px; border-radius: 999px;
286
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
287
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
288
+ font: inherit; font-size: 13px; box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16));
289
+ cursor: pointer; transition: background-color 120ms ease;
290
+ }
291
+ .dsh-tw-sync:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
292
+ .dsh-tw-sync:disabled { opacity: .65; cursor: default; }
293
+ .dsh-tw-sync-icon { display: inline-flex; flex: none; }
294
+ .dsh-tw-sync-spin .dsh-tw-sync-icon svg { animation: dsh-tw-spin 1s linear infinite; transform-origin: center; }
295
+ @keyframes dsh-tw-spin { to { transform: rotate(360deg); } }
296
+ .dsh-tw-sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
297
+ .dsh-tw-sync-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
298
+ .dsh-tw-sync-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
299
+ .dsh-tw-sync-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
300
+ .dsh-tw-sync-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
301
+
219
302
  /* ── toast ──────────────────────────────────────────────────── */
220
303
  .dsh-tw-toast {
221
304
  position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%);