@tom2012/dsh-pomodoro-overlay 0.0.0-stage → 0.1.2

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/src/client.js ADDED
@@ -0,0 +1,235 @@
1
+ import React from 'react'
2
+
3
+ import { createPomodoroStore } from './browser-store.js'
4
+ import { findChatOverlay } from './chat-geometry.js'
5
+ import { fontStack } from './font.js'
6
+ import { CSS } from './styles.js'
7
+ import { PomodoroSettings } from './settings.js'
8
+ import { FOCUS } from './timer-core.js'
9
+
10
+ const h = React.createElement
11
+ const PACKAGE_NAME = '@tom2012/dsh-pomodoro-overlay'
12
+ const FORM_ID = 'pomodoro-overlay'
13
+
14
+ function phaseName(phase) {
15
+ return phase === FOCUS ? '专注' : '休息'
16
+ }
17
+
18
+ function fitCountdown(root, number) {
19
+ if (!root || !number) return
20
+ if (root.hidden || root.clientWidth === 0 || root.clientHeight === 0) {
21
+ root.__dpoFitKey = ''
22
+ return
23
+ }
24
+ const computed = getComputedStyle(number)
25
+ const text = number.textContent || '00:00'
26
+ const key = [root.clientWidth, root.clientHeight, text, computed.fontFamily, computed.fontWeight].join('|')
27
+ if (root.__dpoFitKey === key) return
28
+ root.__dpoFitKey = key
29
+
30
+ const availableWidth = Math.max(32, root.clientWidth - 32)
31
+ const availableHeight = Math.max(32, root.clientHeight - 64)
32
+ let widthAt100 = availableWidth / 3
33
+ const canvas = document.createElement('canvas')
34
+ const context = canvas.getContext('2d')
35
+ if (context) {
36
+ context.font = `${computed.fontWeight} 100px ${computed.fontFamily}`
37
+ // The display face uses -0.065em tracking; include it in the fit estimate.
38
+ widthAt100 = Math.max(1, context.measureText(text).width - (text.length - 1) * 6.5)
39
+ }
40
+ const size = Math.max(24, Math.floor(Math.min(
41
+ availableHeight,
42
+ availableWidth * 100 / widthAt100,
43
+ )))
44
+ number.style.fontSize = `${size}px`
45
+ }
46
+
47
+ function ActiveOverlay({ snapshot }) {
48
+ const rootRef = React.useRef(null)
49
+ const numberRef = React.useRef(null)
50
+
51
+ React.useLayoutEffect(() => {
52
+ fitCountdown(rootRef.current, numberRef.current)
53
+ }, [snapshot.text, snapshot.config.fontFamily])
54
+
55
+ React.useEffect(() => {
56
+ const root = rootRef.current
57
+ if (!root) return undefined
58
+ let disposed = false
59
+ let frame = null
60
+ let mutationTimer = null
61
+ let observedChat = null
62
+ let observedComposer = null
63
+
64
+ const geometryObserver = typeof ResizeObserver === 'function'
65
+ ? new ResizeObserver(() => scheduleFrame())
66
+ : null
67
+ const numberObserver = typeof ResizeObserver === 'function'
68
+ ? new ResizeObserver(() => fitCountdown(root, numberRef.current))
69
+ : null
70
+
71
+ function update() {
72
+ if (disposed) return
73
+ const match = findChatOverlay(document)
74
+ const nextChat = match?.chat ?? null
75
+ const nextComposer = nextChat?.querySelector('[data-composer-seat]') ?? null
76
+ if (nextChat !== observedChat || nextComposer !== observedComposer) {
77
+ observedChat = nextChat
78
+ observedComposer = nextComposer
79
+ geometryObserver?.disconnect()
80
+ if (observedChat) geometryObserver?.observe(observedChat)
81
+ if (observedComposer) geometryObserver?.observe(observedComposer)
82
+ }
83
+
84
+ if (!match) {
85
+ root.hidden = true
86
+ root.dataset.dpoGeometry = 'hidden'
87
+ return
88
+ }
89
+ const { rect } = match
90
+ root.style.left = `${rect.left}px`
91
+ root.style.top = `${rect.top}px`
92
+ root.style.width = `${rect.width}px`
93
+ root.style.height = `${rect.height}px`
94
+ root.hidden = false
95
+ root.dataset.dpoGeometry = 'active'
96
+ fitCountdown(root, numberRef.current)
97
+ }
98
+
99
+ function scheduleFrame() {
100
+ if (disposed || frame !== null) return
101
+ frame = requestAnimationFrame(() => {
102
+ frame = null
103
+ update()
104
+ })
105
+ }
106
+
107
+ function scheduleMutationCheck() {
108
+ if (disposed || mutationTimer !== null) return
109
+ // Streaming messages can mutate often; rebind at most once per 160ms.
110
+ mutationTimer = window.setTimeout(() => {
111
+ mutationTimer = null
112
+ update()
113
+ }, 160)
114
+ }
115
+
116
+ const mutationObserver = typeof MutationObserver === 'function'
117
+ ? new MutationObserver(scheduleMutationCheck)
118
+ : null
119
+ mutationObserver?.observe(document.body, {
120
+ childList: true,
121
+ subtree: true,
122
+ attributes: true,
123
+ attributeFilter: ['data-conversation-content', 'data-chat-flow', 'data-composer-seat', 'hidden', 'inert'],
124
+ })
125
+ numberObserver?.observe(root)
126
+ window.addEventListener('resize', scheduleFrame)
127
+ window.addEventListener('scroll', scheduleFrame, true)
128
+ update()
129
+
130
+ return () => {
131
+ disposed = true
132
+ geometryObserver?.disconnect()
133
+ numberObserver?.disconnect()
134
+ mutationObserver?.disconnect()
135
+ if (frame !== null) cancelAnimationFrame(frame)
136
+ if (mutationTimer !== null) clearTimeout(mutationTimer)
137
+ window.removeEventListener('resize', scheduleFrame)
138
+ window.removeEventListener('scroll', scheduleFrame, true)
139
+ }
140
+ }, [])
141
+
142
+ const label = phaseName(snapshot.phase)
143
+ const status = snapshot.running ? label : `${label} · 已暂停`
144
+ const appearance = snapshot.phase === FOCUS
145
+ ? { color: snapshot.config.focusColor, opacity: snapshot.config.focusOpacity }
146
+ : { color: snapshot.config.breakColor, opacity: snapshot.config.breakOpacity }
147
+
148
+ return h('div', {
149
+ ref: rootRef,
150
+ className: 'dpo-overlay',
151
+ hidden: true,
152
+ role: 'group',
153
+ 'aria-label': `番茄钟,${status}`,
154
+ 'data-dpo-overlay': '',
155
+ 'data-phase': snapshot.phase,
156
+ 'data-running': String(snapshot.running),
157
+ 'data-dpo-geometry': 'hidden',
158
+ }, [
159
+ h('div', {
160
+ key: 'number',
161
+ ref: numberRef,
162
+ className: 'dpo-number',
163
+ 'data-dpo-number': '',
164
+ 'data-dpo-color': appearance.color,
165
+ 'data-dpo-opacity': String(appearance.opacity),
166
+ 'data-dpo-font-family': snapshot.config.fontFamily,
167
+ 'aria-hidden': 'true',
168
+ style: {
169
+ color: appearance.color,
170
+ opacity: appearance.opacity,
171
+ fontFamily: fontStack(snapshot.config.fontFamily),
172
+ },
173
+ }, snapshot.text),
174
+ h('span', {
175
+ key: 'accessible-time',
176
+ className: 'dpo-sr-only',
177
+ role: 'timer',
178
+ 'aria-label': `${status},剩余 ${snapshot.text}`,
179
+ }, `${status},剩余 ${snapshot.text}`),
180
+ ])
181
+ }
182
+
183
+ function PomodoroOverlay({ store }) {
184
+ const subscribe = React.useCallback((listener) => store.subscribe(listener), [store])
185
+ const getSnapshot = React.useCallback(() => store.getSnapshot(), [store])
186
+ const snapshot = React.useSyncExternalStore(subscribe, getSnapshot)
187
+ if (!snapshot.active) return null
188
+ return h(ActiveOverlay, { snapshot })
189
+ }
190
+
191
+ export const name = PACKAGE_NAME
192
+ export const inject = ['slots', 'configForms']
193
+
194
+ export function apply(ctx) {
195
+ const form = ctx.configForms.get(FORM_ID)
196
+ const store = createPomodoroStore()
197
+
198
+ ctx.effect(() => {
199
+ const syncConfig = () => {
200
+ const snapshot = form.getSnapshot()
201
+ if (snapshot.status !== 'ready' || !snapshot.value) return
202
+ try {
203
+ store.setConfig(snapshot.value)
204
+ store.start()
205
+ } catch (error) {
206
+ ctx.logger?.error?.('Pomodoro Overlay could not accept its configuration', error)
207
+ }
208
+ }
209
+ const unsubscribe = form.subscribe(syncConfig)
210
+ syncConfig()
211
+ return () => {
212
+ unsubscribe()
213
+ store.dispose()
214
+ }
215
+ }, 'pomodoro-overlay: native configuration and timer')
216
+
217
+ ctx.effect(() => {
218
+ const style = document.createElement('style')
219
+ style.setAttribute('data-dpo-style', '')
220
+ style.setAttribute('data-plugin', PACKAGE_NAME)
221
+ style.textContent = CSS
222
+ document.head.appendChild(style)
223
+ return () => style.remove()
224
+ }, 'pomodoro-overlay: styles')
225
+
226
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register(
227
+ { name: 'shell.overlay', id: PACKAGE_NAME, order: -100, label: '番茄钟' },
228
+ () => h(PomodoroOverlay, { store }),
229
+ ))
230
+
231
+ ctx.slots.inject('settings.section', () => ctx.slots.register(
232
+ { name: 'settings.section', id: 'pomodoro', order: 30, label: '番茄钟' },
233
+ () => h(PomodoroSettings, { form, store }),
234
+ ))
235
+ }
package/src/font.js ADDED
@@ -0,0 +1,18 @@
1
+ export const FONT_OPTIONS = Object.freeze([
2
+ { value: '等线', label: 'DengXian(默认)', stack: '"DengXian", "等线", sans-serif' },
3
+ { value: 'system-ui', label: '系统无衬线', stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' },
4
+ { value: 'monospace', label: '等宽字体', stack: 'ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace' },
5
+ { value: 'Arial', label: 'Arial', stack: 'Arial, Helvetica, sans-serif' },
6
+ { value: 'Courier New', label: 'Courier New', stack: '"Courier New", Courier, monospace' },
7
+ { value: 'Georgia', label: 'Georgia', stack: 'Georgia, "Times New Roman", serif' },
8
+ ])
9
+
10
+ /** Resolve a single installed font name to a safe local-only CSS font stack. */
11
+ export function fontStack(family) {
12
+ if (typeof family !== 'string' || family.trim() === '') return 'sans-serif'
13
+ const normalized = family.trim()
14
+ const known = FONT_OPTIONS.find((option) => option.value === normalized)
15
+ if (known) return known.stack
16
+ const escaped = normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
17
+ return `"${escaped}", sans-serif`
18
+ }
@@ -0,0 +1,329 @@
1
+ import React from 'react'
2
+
3
+ import { FONT_OPTIONS, fontStack } from './font.js'
4
+ import {
5
+ DEFAULT_CONFIG,
6
+ FOCUS,
7
+ isValidColor,
8
+ isValidFontFamily,
9
+ isValidMinutes,
10
+ parseConfig,
11
+ } from './timer-core.js'
12
+
13
+ const h = React.createElement
14
+ const MINUTES_FIELDS = ['focusMinutes', 'breakMinutes']
15
+ const COLOR_FIELDS = ['focusColor', 'breakColor']
16
+ const OPACITY_FIELDS = ['focusOpacity', 'breakOpacity']
17
+
18
+ function phaseName(phase) {
19
+ return phase === FOCUS ? '专注' : '休息'
20
+ }
21
+
22
+ function draftFromConfig(value = DEFAULT_CONFIG) {
23
+ const source = { ...DEFAULT_CONFIG, ...value }
24
+ return {
25
+ focusMinutes: String(source.focusMinutes),
26
+ breakMinutes: String(source.breakMinutes),
27
+ focusColor: source.focusColor,
28
+ breakColor: source.breakColor,
29
+ focusOpacity: String(Math.round(source.focusOpacity * 100)),
30
+ breakOpacity: String(Math.round(source.breakOpacity * 100)),
31
+ fontFamily: source.fontFamily,
32
+ }
33
+ }
34
+
35
+ function isValidPercent(value) {
36
+ if (typeof value === 'string' && value.trim() === '') return false
37
+ const parsed = Number(value)
38
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100
39
+ }
40
+
41
+ function configFromDraft(draft) {
42
+ if (!MINUTES_FIELDS.every((field) => isValidMinutes(draft[field]))) {
43
+ throw new RangeError('工作和休息时长均需为 1–240 之间的整数分钟。')
44
+ }
45
+ if (!COLOR_FIELDS.every((field) => isValidColor(draft[field]))) {
46
+ throw new TypeError('颜色需使用 #RRGGBB 格式。')
47
+ }
48
+ if (!OPACITY_FIELDS.every((field) => isValidPercent(draft[field]))) {
49
+ throw new RangeError('数字不透明度需为 0–100 的整数百分比。')
50
+ }
51
+ if (!isValidFontFamily(draft.fontFamily.trim())) {
52
+ throw new TypeError('请输入一个本机字体名称(1–80 个字符,不包含引号或逗号)。')
53
+ }
54
+ return parseConfig({
55
+ focusMinutes: Number(draft.focusMinutes),
56
+ breakMinutes: Number(draft.breakMinutes),
57
+ focusColor: draft.focusColor,
58
+ breakColor: draft.breakColor,
59
+ focusOpacity: Number(draft.focusOpacity) / 100,
60
+ breakOpacity: Number(draft.breakOpacity) / 100,
61
+ fontFamily: draft.fontFamily.trim(),
62
+ })
63
+ }
64
+
65
+ function useFormSnapshot(form) {
66
+ const subscribe = React.useCallback((listener) => form.subscribe(listener), [form])
67
+ const getSnapshot = React.useCallback(() => form.getSnapshot(), [form])
68
+ return React.useSyncExternalStore(subscribe, getSnapshot)
69
+ }
70
+
71
+ export function PomodoroSettings({ form, store }) {
72
+ const formSnapshot = useFormSnapshot(form)
73
+ const timerSnapshot = useFormSnapshot(store)
74
+ const [draft, setDraft] = React.useState(() => draftFromConfig(form.getSnapshot().value))
75
+ const [baseRevision, setBaseRevision] = React.useState(form.getSnapshot().revision)
76
+ const [dirty, setDirty] = React.useState(false)
77
+ const [saving, setSaving] = React.useState(false)
78
+ const [error, setError] = React.useState('')
79
+ const [notice, setNotice] = React.useState('')
80
+ const alive = React.useRef(true)
81
+
82
+ React.useEffect(() => {
83
+ alive.current = true
84
+ return () => { alive.current = false }
85
+ }, [])
86
+
87
+ const loadLatest = React.useCallback((clearFeedback = true) => {
88
+ const latest = form.getSnapshot()
89
+ if (latest.status === 'ready' && latest.value) {
90
+ setDraft(draftFromConfig(latest.value))
91
+ setBaseRevision(latest.revision)
92
+ setDirty(false)
93
+ if (clearFeedback) {
94
+ setError('')
95
+ setNotice('')
96
+ }
97
+ }
98
+ }, [form])
99
+
100
+ React.useEffect(() => {
101
+ if (!dirty && formSnapshot.status === 'ready' && formSnapshot.value) loadLatest(false)
102
+ }, [formSnapshot, dirty, loadLatest])
103
+
104
+ function change(field, value) {
105
+ setDraft((current) => ({ ...current, [field]: value }))
106
+ setDirty(true)
107
+ setError('')
108
+ setNotice('')
109
+ }
110
+
111
+ async function save(event) {
112
+ event.preventDefault()
113
+ if (saving || !dirty || !formSnapshot.writable) return
114
+ let next
115
+ try {
116
+ next = configFromDraft(draft)
117
+ } catch (validationError) {
118
+ setError(validationError.message)
119
+ setNotice('')
120
+ return
121
+ }
122
+ setSaving(true)
123
+ setError('')
124
+ setNotice('')
125
+ try {
126
+ const accepted = await form.mutate([
127
+ { op: 'set', path: ['focusMinutes'], value: next.focusMinutes },
128
+ { op: 'set', path: ['breakMinutes'], value: next.breakMinutes },
129
+ { op: 'set', path: ['focusColor'], value: next.focusColor },
130
+ { op: 'set', path: ['breakColor'], value: next.breakColor },
131
+ { op: 'set', path: ['focusOpacity'], value: next.focusOpacity },
132
+ { op: 'set', path: ['breakOpacity'], value: next.breakOpacity },
133
+ { op: 'set', path: ['fontFamily'], value: next.fontFamily },
134
+ ], baseRevision)
135
+ if (!alive.current) return
136
+ if (accepted) {
137
+ setDirty(false)
138
+ setNotice('已保存。字体、颜色和不透明度已应用;新时长从下一阶段生效。')
139
+ } else {
140
+ setError('设置已在其他页面变化,本次未覆盖。草稿已保留,请核对最新值后重试。')
141
+ }
142
+ } catch {
143
+ if (alive.current) setError('保存失败,请检查连接后重试。草稿已保留。')
144
+ } finally {
145
+ if (alive.current) setSaving(false)
146
+ }
147
+ }
148
+
149
+ const ready = formSnapshot.status === 'ready' && Boolean(formSnapshot.value)
150
+ const disabled = !ready || !formSnapshot.writable || saving
151
+
152
+ function field(name, title, description, control) {
153
+ return h('div', { className: 'dpo-field', key: name }, [
154
+ h('div', { className: 'dpo-field-copy', key: 'copy' }, [
155
+ h('label', { htmlFor: `dpo-settings-${name}`, key: 'label' }, title),
156
+ h('span', { className: 'dpo-hint', id: `dpo-settings-${name}-hint`, key: 'hint' }, description),
157
+ ]),
158
+ control,
159
+ ])
160
+ }
161
+
162
+ function phaseCard(phase) {
163
+ const minutes = `${phase}Minutes`
164
+ const color = `${phase}Color`
165
+ const opacity = `${phase}Opacity`
166
+ const label = phaseName(phase)
167
+ const previewOpacity = isValidPercent(draft[opacity]) ? Number(draft[opacity]) / 100 : 0
168
+ return h('fieldset', {
169
+ className: 'dpo-phase-card',
170
+ 'data-dpo-phase-settings': phase,
171
+ key: phase,
172
+ disabled,
173
+ }, [
174
+ h('legend', { key: 'legend' }, label),
175
+ h('p', { className: 'dpo-hint', key: 'phase-hint' },
176
+ phase === FOCUS ? '工作阶段的周期与大数字外观' : '休息阶段的周期与大数字外观'),
177
+ field(minutes, `${label}时长`, '1–240 分钟', h('input', {
178
+ key: 'input',
179
+ id: `dpo-settings-${minutes}`,
180
+ className: 'dpo-input',
181
+ 'data-dpo-setting': minutes,
182
+ type: 'number',
183
+ min: 1,
184
+ max: 240,
185
+ step: 1,
186
+ inputMode: 'numeric',
187
+ value: draft[minutes],
188
+ onChange: (event) => change(minutes, event.currentTarget.value),
189
+ 'aria-describedby': `dpo-settings-${minutes}-hint`,
190
+ })),
191
+ field(color, '数字颜色', '六位十六进制颜色', h('div', { className: 'dpo-color-control', key: 'input' }, [
192
+ h('input', {
193
+ key: 'color',
194
+ id: `dpo-settings-${color}`,
195
+ className: 'dpo-color-input',
196
+ 'data-dpo-setting': color,
197
+ type: 'color',
198
+ value: draft[color],
199
+ onChange: (event) => change(color, event.currentTarget.value),
200
+ 'aria-describedby': `dpo-settings-${color}-hint`,
201
+ }),
202
+ h('code', { key: 'value', className: 'dpo-color-value' }, draft[color].toUpperCase()),
203
+ ])),
204
+ field(opacity, '数字不透明度', '0% 完全透明,100% 不透明', h('div', { className: 'dpo-range-control', key: 'input' }, [
205
+ h('input', {
206
+ key: 'range',
207
+ id: `dpo-settings-${opacity}`,
208
+ className: 'dpo-range',
209
+ 'data-dpo-setting': opacity,
210
+ type: 'range',
211
+ min: 0,
212
+ max: 100,
213
+ step: 1,
214
+ value: draft[opacity],
215
+ onChange: (event) => change(opacity, event.currentTarget.value),
216
+ 'aria-valuetext': `${draft[opacity]}%`,
217
+ 'aria-describedby': `dpo-settings-${opacity}-hint`,
218
+ }),
219
+ h('output', {
220
+ key: 'output',
221
+ className: 'dpo-output',
222
+ htmlFor: `dpo-settings-${opacity}`,
223
+ 'data-dpo-opacity-value': phase,
224
+ }, `${draft[opacity]}%`),
225
+ ])),
226
+ h('div', {
227
+ className: 'dpo-preview',
228
+ 'data-dpo-preview': phase,
229
+ key: 'preview',
230
+ style: {
231
+ '--dpo-preview-color': draft[color],
232
+ '--dpo-preview-opacity': previewOpacity,
233
+ '--dpo-preview-font': fontStack(draft.fontFamily),
234
+ },
235
+ }, [
236
+ h('span', { className: 'dpo-hint', key: 'label' }, '草稿预览'),
237
+ h('span', { className: 'dpo-preview-number', 'aria-hidden': 'true', key: 'number' },
238
+ phase === FOCUS ? '25:00' : '05:00'),
239
+ ]),
240
+ ])
241
+ }
242
+
243
+ function fontCard() {
244
+ return h('fieldset', {
245
+ className: 'dpo-phase-card',
246
+ 'data-dpo-font-settings': '',
247
+ key: 'font',
248
+ disabled,
249
+ }, [
250
+ h('legend', { key: 'legend' }, '时钟字体'),
251
+ h('p', { className: 'dpo-hint', key: 'font-hint' }, '专注与休息共用;只使用本机字体,不发起字体下载。'),
252
+ field('fontFamily', '字体名称', '默认等线(DengXian),可输入其他本机字体', h('div', {
253
+ className: 'dpo-font-control',
254
+ key: 'input',
255
+ }, [
256
+ h('input', {
257
+ key: 'font',
258
+ id: 'dpo-settings-fontFamily',
259
+ className: 'dpo-input',
260
+ 'data-dpo-setting': 'fontFamily',
261
+ type: 'text',
262
+ list: 'dpo-font-options',
263
+ maxLength: 80,
264
+ autoComplete: 'off',
265
+ autoCapitalize: 'off',
266
+ spellCheck: false,
267
+ value: draft.fontFamily,
268
+ onChange: (event) => change('fontFamily', event.currentTarget.value),
269
+ 'aria-describedby': 'dpo-settings-fontFamily-hint',
270
+ }),
271
+ h('datalist', { id: 'dpo-font-options', key: 'options' }, FONT_OPTIONS.map((option) => h('option', {
272
+ key: option.value,
273
+ value: option.value,
274
+ label: option.label,
275
+ }))),
276
+ ])),
277
+ ])
278
+ }
279
+
280
+ return h('section', { className: 'dpo-settings', 'data-dpo-settings': '' }, [
281
+ h('h2', { key: 'title' }, '番茄钟'),
282
+ h('p', { className: 'dpo-description', key: 'description' },
283
+ '自动进行「专注 → 休息」循环。倒计时仅覆盖聊天消息区域;此处预览草稿,保存后应用。'),
284
+ formSnapshot.status === 'loading'
285
+ ? h('p', { key: 'loading', role: 'status' }, '正在读取番茄钟设置…')
286
+ : null,
287
+ formSnapshot.status === 'unavailable'
288
+ ? h('p', { key: 'unavailable', className: 'dpo-error', role: 'alert' },
289
+ '当前客户端无法读取番茄钟配置。')
290
+ : null,
291
+ ready ? h('form', { key: 'form', className: 'dpo-form', noValidate: true, onSubmit: save }, [
292
+ fontCard(),
293
+ phaseCard(FOCUS),
294
+ phaseCard('break'),
295
+ h('p', { className: 'dpo-hint', key: 'rule' },
296
+ '保存不打断当前计时。字体、颜色和不透明度立即生效;新时长从下一阶段开始生效。'),
297
+ h('div', { className: 'dpo-actions', key: 'actions' }, [
298
+ h('button', {
299
+ key: 'save',
300
+ type: 'submit',
301
+ className: 'dpo-button dpo-primary',
302
+ 'data-dpo-save': '',
303
+ disabled: disabled || !dirty,
304
+ }, saving ? '保存中…' : '保存设置'),
305
+ h('button', {
306
+ key: 'discard',
307
+ type: 'button',
308
+ className: 'dpo-button',
309
+ 'data-dpo-discard': '',
310
+ disabled: saving || !dirty,
311
+ onClick: () => loadLatest(),
312
+ }, '放弃修改'),
313
+ ]),
314
+ ]) : null,
315
+ !formSnapshot.writable && ready
316
+ ? h('p', { key: 'readonly', className: 'dpo-warning' }, '当前配置为只读。')
317
+ : null,
318
+ error ? h('p', { key: 'error', className: 'dpo-error', role: 'alert' }, error) : null,
319
+ notice ? h('p', { key: 'notice', className: 'dpo-success', role: 'status' }, notice) : null,
320
+ !timerSnapshot.storageAvailable
321
+ ? h('p', { key: 'storage', className: 'dpo-warning', role: 'status' },
322
+ '浏览器存储不可用:计时仍在运行,但刷新后可能重新开始。')
323
+ : null,
324
+ timerSnapshot.active ? h('div', { className: 'dpo-current', key: 'current' }, [
325
+ h('span', { key: 'phase' }, `当前阶段:${phaseName(timerSnapshot.phase)}${timerSnapshot.running ? '' : '(已暂停)'}`),
326
+ h('span', { key: 'time' }, `剩余:${timerSnapshot.text}`),
327
+ ]) : null,
328
+ ])
329
+ }