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

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/styles.js ADDED
@@ -0,0 +1,230 @@
1
+ export const CSS = String.raw`
2
+ .dpo-overlay {
3
+ position: fixed;
4
+ z-index: 1;
5
+ box-sizing: border-box;
6
+ display: grid;
7
+ place-items: center;
8
+ overflow: hidden;
9
+ pointer-events: none;
10
+ container-type: inline-size;
11
+ container-name: dpo;
12
+ color: var(--dsw-alias-label-primary);
13
+ font-family: inherit;
14
+ }
15
+ .dpo-overlay[hidden] { display: none !important; }
16
+ .dpo-number {
17
+ font-family: var(--ds-font-family-code, ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace);
18
+ font-size: 120px;
19
+ font-weight: 600;
20
+ font-variant-numeric: tabular-nums lining-nums;
21
+ letter-spacing: -0.065em;
22
+ line-height: 1;
23
+ white-space: nowrap;
24
+ user-select: none;
25
+ transform: translateX(-0.0325em);
26
+ transition: color 160ms ease-out, opacity 160ms ease-out;
27
+ }
28
+ .dpo-button {
29
+ box-sizing: border-box;
30
+ min-width: 44px;
31
+ min-height: 44px;
32
+ border: 0;
33
+ border-radius: 8px;
34
+ padding: 0 12px;
35
+ background: transparent;
36
+ color: var(--dsw-alias-label-secondary);
37
+ font: inherit;
38
+ font-size: 12px;
39
+ line-height: 20px;
40
+ cursor: pointer;
41
+ transition: background-color 120ms ease-out, color 120ms ease-out;
42
+ }
43
+ .dpo-button:hover:not(:disabled) {
44
+ background: var(--dsw-alias-interactive-bg-hover);
45
+ color: var(--dsw-alias-label-primary);
46
+ }
47
+ .dpo-button:active:not(:disabled) {
48
+ background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
49
+ }
50
+ .dpo-button:focus-visible,
51
+ .dpo-input:focus-visible,
52
+ .dpo-color-input:focus-visible,
53
+ .dpo-range:focus-visible {
54
+ outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
55
+ outline-offset: 2px;
56
+ }
57
+ .dpo-button:disabled,
58
+ .dpo-input:disabled {
59
+ opacity: 0.55;
60
+ cursor: default;
61
+ }
62
+ .dpo-sr-only {
63
+ position: absolute !important;
64
+ width: 1px !important;
65
+ height: 1px !important;
66
+ padding: 0 !important;
67
+ margin: -1px !important;
68
+ overflow: hidden !important;
69
+ clip: rect(0, 0, 0, 0) !important;
70
+ white-space: nowrap !important;
71
+ border: 0 !important;
72
+ }
73
+
74
+ .dpo-settings {
75
+ box-sizing: border-box;
76
+ max-width: 680px;
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 16px;
80
+ color: var(--dsw-alias-label-primary);
81
+ font-size: 14px;
82
+ line-height: 22px;
83
+ }
84
+ .dpo-settings h2,
85
+ .dpo-settings h3,
86
+ .dpo-settings p { margin: 0; }
87
+ .dpo-settings h2 { font-size: 18px; line-height: 28px; font-weight: 550; }
88
+ .dpo-settings h3 { font-size: 14px; line-height: 22px; font-weight: 500; }
89
+ .dpo-description { color: var(--dsw-alias-label-secondary); }
90
+ .dpo-hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
91
+ .dpo-form {
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 16px;
95
+ }
96
+ .dpo-phase-card {
97
+ box-sizing: border-box;
98
+ min-width: 0;
99
+ margin: 0;
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 16px;
103
+ border: 1px solid var(--dsw-alias-settings-card-stroke, var(--dsw-alias-border-l2));
104
+ border-radius: var(--dsw-radius-xl, 16px);
105
+ padding: 16px;
106
+ background: var(--dsw-alias-settings-card-fill, var(--dsw-alias-bg-base));
107
+ }
108
+ .dpo-phase-card > legend {
109
+ padding: 0 4px;
110
+ font-size: 14px;
111
+ font-weight: 600;
112
+ }
113
+ .dpo-field {
114
+ display: grid;
115
+ grid-template-columns: minmax(0, 1fr) minmax(120px, 160px);
116
+ align-items: center;
117
+ gap: 16px;
118
+ }
119
+ .dpo-field-copy { display: flex; flex-direction: column; gap: 2px; }
120
+ .dpo-field-copy label { color: var(--dsw-alias-label-primary); font-weight: 500; }
121
+ .dpo-input {
122
+ box-sizing: border-box;
123
+ width: 100%;
124
+ min-height: 44px;
125
+ border: 1px solid var(--dsw-alias-border-l3);
126
+ border-radius: var(--dsw-radius-md, 8px);
127
+ padding: 8px 12px;
128
+ background: var(--dsw-alias-bg-base);
129
+ color: var(--dsw-alias-label-primary);
130
+ font: inherit;
131
+ font-variant-numeric: tabular-nums;
132
+ }
133
+ .dpo-font-control { min-width: 0; }
134
+ .dpo-color-control {
135
+ min-height: 44px;
136
+ display: flex;
137
+ align-items: center;
138
+ gap: 12px;
139
+ }
140
+ .dpo-color-input {
141
+ box-sizing: border-box;
142
+ width: 52px;
143
+ height: 44px;
144
+ border: 1px solid var(--dsw-alias-border-l3);
145
+ border-radius: var(--dsw-radius-md, 8px);
146
+ padding: 4px;
147
+ background: var(--dsw-alias-bg-base);
148
+ cursor: pointer;
149
+ }
150
+ .dpo-color-value {
151
+ color: var(--dsw-alias-label-secondary);
152
+ font-family: var(--ds-font-family-code, ui-monospace, monospace);
153
+ font-size: 12px;
154
+ }
155
+ .dpo-range-control {
156
+ min-height: 44px;
157
+ display: grid;
158
+ grid-template-columns: minmax(80px, 1fr) 48px;
159
+ align-items: center;
160
+ gap: 12px;
161
+ }
162
+ .dpo-range {
163
+ width: 100%;
164
+ min-height: 44px;
165
+ margin: 0;
166
+ accent-color: var(--dsw-alias-brand-primary);
167
+ cursor: pointer;
168
+ }
169
+ .dpo-output {
170
+ text-align: right;
171
+ font-variant-numeric: tabular-nums;
172
+ color: var(--dsw-alias-label-secondary);
173
+ }
174
+ .dpo-preview {
175
+ min-height: 88px;
176
+ display: flex;
177
+ flex-direction: column;
178
+ align-items: center;
179
+ justify-content: center;
180
+ gap: 4px;
181
+ overflow: hidden;
182
+ border: 1px dashed var(--dsw-alias-border-l3);
183
+ border-radius: var(--dsw-radius-lg, 12px);
184
+ background: var(--dsw-alias-bg-base);
185
+ }
186
+ .dpo-preview-number {
187
+ color: var(--dpo-preview-color);
188
+ opacity: var(--dpo-preview-opacity);
189
+ font-family: var(--dpo-preview-font, var(--ds-font-family-code, ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace));
190
+ font-size: 42px;
191
+ font-weight: 600;
192
+ font-variant-numeric: tabular-nums lining-nums;
193
+ line-height: 1;
194
+ letter-spacing: -0.065em;
195
+ user-select: none;
196
+ }
197
+ .dpo-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
198
+ .dpo-settings .dpo-button { border: 1px solid var(--dsw-alias-border-l3); }
199
+ .dpo-settings .dpo-primary {
200
+ border-color: transparent;
201
+ background: var(--dsw-alias-brand-primary);
202
+ color: var(--dsw-alias-label-primary-inverted, #fff);
203
+ }
204
+ .dpo-settings .dpo-primary:hover:not(:disabled) {
205
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary) 88%, var(--dsw-alias-label-primary));
206
+ color: var(--dsw-alias-label-primary-inverted, #fff);
207
+ }
208
+ .dpo-error { color: var(--dsw-alias-state-error-primary); font-size: 13px; line-height: 20px; }
209
+ .dpo-success { color: var(--dsw-alias-state-success-primary); font-size: 13px; line-height: 20px; }
210
+ .dpo-warning { color: var(--dsw-alias-state-warn-label); font-size: 13px; line-height: 20px; }
211
+ .dpo-current {
212
+ display: flex;
213
+ flex-wrap: wrap;
214
+ gap: 8px 16px;
215
+ color: var(--dsw-alias-label-secondary);
216
+ font-size: 13px;
217
+ }
218
+
219
+ @media (max-width: 520px) {
220
+ .dpo-field { grid-template-columns: 1fr; gap: 8px; }
221
+ .dpo-actions .dpo-button { flex: 1 1 120px; }
222
+ }
223
+ @media (pointer: coarse) {
224
+ .dpo-button, .dpo-input, .dpo-range { min-height: 48px; }
225
+ .dpo-color-input { height: 48px; }
226
+ }
227
+ @media (prefers-reduced-motion: reduce) {
228
+ .dpo-number, .dpo-button { transition: none; }
229
+ }
230
+ `
@@ -0,0 +1,254 @@
1
+ export const FOCUS = 'focus'
2
+ export const BREAK = 'break'
3
+ export const STATE_VERSION = 1
4
+ export const MIN_MINUTES = 1
5
+ export const MAX_MINUTES = 240
6
+ export const MINUTE_MS = 60_000
7
+
8
+ export const DEFAULT_CONFIG = Object.freeze({
9
+ focusMinutes: 25,
10
+ breakMinutes: 5,
11
+ focusColor: '#9ca3af',
12
+ breakColor: '#ef4444',
13
+ focusOpacity: 0.1,
14
+ breakOpacity: 0.3,
15
+ fontFamily: '等线',
16
+ })
17
+
18
+ function numeric(value) {
19
+ if (typeof value === 'number') return value
20
+ if (typeof value === 'string' && value.trim() !== '') return Number(value)
21
+ return Number.NaN
22
+ }
23
+
24
+ export function isValidMinutes(value) {
25
+ const parsed = numeric(value)
26
+ return Number.isInteger(parsed) && parsed >= MIN_MINUTES && parsed <= MAX_MINUTES
27
+ }
28
+
29
+ export function isValidColor(value) {
30
+ return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
31
+ }
32
+
33
+ export function isValidOpacity(value) {
34
+ const parsed = numeric(value)
35
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1
36
+ }
37
+
38
+ export function isValidFontFamily(value) {
39
+ return typeof value === 'string' && value.length <= 80
40
+ && /^[\p{L}\p{N}][\p{L}\p{N} _.-]*$/u.test(value)
41
+ }
42
+
43
+ /** Strict validation for an explicit settings save. */
44
+ export function parseConfig(value) {
45
+ if (!isValidMinutes(value?.focusMinutes) || !isValidMinutes(value?.breakMinutes)) {
46
+ throw new RangeError(`Durations must be integers from ${MIN_MINUTES} to ${MAX_MINUTES} minutes`)
47
+ }
48
+ const parsed = {
49
+ focusMinutes: numeric(value.focusMinutes),
50
+ breakMinutes: numeric(value.breakMinutes),
51
+ focusColor: value.focusColor === undefined ? DEFAULT_CONFIG.focusColor : value.focusColor,
52
+ breakColor: value.breakColor === undefined ? DEFAULT_CONFIG.breakColor : value.breakColor,
53
+ focusOpacity: value.focusOpacity === undefined ? DEFAULT_CONFIG.focusOpacity : value.focusOpacity,
54
+ breakOpacity: value.breakOpacity === undefined ? DEFAULT_CONFIG.breakOpacity : value.breakOpacity,
55
+ fontFamily: value.fontFamily === undefined ? DEFAULT_CONFIG.fontFamily : value.fontFamily,
56
+ }
57
+ if (!isValidColor(parsed.focusColor) || !isValidColor(parsed.breakColor)) {
58
+ throw new TypeError('Colors must use six-digit hex format (#rrggbb)')
59
+ }
60
+ if (!isValidOpacity(parsed.focusOpacity) || !isValidOpacity(parsed.breakOpacity)) {
61
+ throw new RangeError('Opacity values must be between 0 and 1')
62
+ }
63
+ if (!isValidFontFamily(parsed.fontFamily)) {
64
+ throw new TypeError('Font family must be a single installed font name')
65
+ }
66
+ parsed.focusOpacity = numeric(parsed.focusOpacity)
67
+ parsed.breakOpacity = numeric(parsed.breakOpacity)
68
+ return parsed
69
+ }
70
+
71
+ /** Non-throwing recovery for a browser-stored snapshot. */
72
+ export function sanitizeConfig(value) {
73
+ return {
74
+ focusMinutes: isValidMinutes(value?.focusMinutes)
75
+ ? numeric(value.focusMinutes)
76
+ : DEFAULT_CONFIG.focusMinutes,
77
+ breakMinutes: isValidMinutes(value?.breakMinutes)
78
+ ? numeric(value.breakMinutes)
79
+ : DEFAULT_CONFIG.breakMinutes,
80
+ focusColor: isValidColor(value?.focusColor) ? value.focusColor : DEFAULT_CONFIG.focusColor,
81
+ breakColor: isValidColor(value?.breakColor) ? value.breakColor : DEFAULT_CONFIG.breakColor,
82
+ focusOpacity: isValidOpacity(value?.focusOpacity)
83
+ ? numeric(value.focusOpacity)
84
+ : DEFAULT_CONFIG.focusOpacity,
85
+ breakOpacity: isValidOpacity(value?.breakOpacity)
86
+ ? numeric(value.breakOpacity)
87
+ : DEFAULT_CONFIG.breakOpacity,
88
+ fontFamily: isValidFontFamily(value?.fontFamily) ? value.fontFamily : DEFAULT_CONFIG.fontFamily,
89
+ }
90
+ }
91
+
92
+ export function configFromJSON(text) {
93
+ if (typeof text !== 'string' || text === '') return { ...DEFAULT_CONFIG }
94
+ try {
95
+ return sanitizeConfig(JSON.parse(text))
96
+ } catch {
97
+ return { ...DEFAULT_CONFIG }
98
+ }
99
+ }
100
+
101
+ export function otherPhase(phase) {
102
+ if (phase === FOCUS) return BREAK
103
+ if (phase === BREAK) return FOCUS
104
+ throw new TypeError(`Unknown Pomodoro phase: ${String(phase)}`)
105
+ }
106
+
107
+ export function durationForPhase(phase, config) {
108
+ otherPhase(phase)
109
+ const valid = parseConfig(config)
110
+ return (phase === FOCUS ? valid.focusMinutes : valid.breakMinutes) * MINUTE_MS
111
+ }
112
+
113
+ function assertFinite(value, label) {
114
+ if (!Number.isFinite(value)) throw new TypeError(`${label} must be finite`)
115
+ }
116
+
117
+ export function createRunningState(phase, config, now, durationOverride) {
118
+ otherPhase(phase)
119
+ assertFinite(now, 'now')
120
+ const durationMs = durationOverride ?? durationForPhase(phase, config)
121
+ assertFinite(durationMs, 'durationMs')
122
+ if (durationMs <= 0) throw new RangeError('durationMs must be positive')
123
+ return {
124
+ version: STATE_VERSION,
125
+ phase,
126
+ running: true,
127
+ startedAt: now,
128
+ endsAt: now + durationMs,
129
+ durationMs,
130
+ remainingMs: null,
131
+ updatedAt: now,
132
+ }
133
+ }
134
+
135
+ export function isValidState(value) {
136
+ if (!value || value.version !== STATE_VERSION) return false
137
+ if (value.phase !== FOCUS && value.phase !== BREAK) return false
138
+ if (typeof value.running !== 'boolean') return false
139
+ if (!Number.isFinite(value.startedAt) || !Number.isFinite(value.updatedAt)) return false
140
+ if (!Number.isFinite(value.durationMs) || value.durationMs <= 0) return false
141
+ if (value.running) {
142
+ return Number.isFinite(value.endsAt)
143
+ && value.endsAt >= 0
144
+ && value.endsAt - value.startedAt === value.durationMs
145
+ && value.remainingMs === null
146
+ }
147
+ return value.endsAt === null
148
+ && Number.isFinite(value.remainingMs)
149
+ && value.remainingMs >= 0
150
+ && value.remainingMs <= value.durationMs
151
+ }
152
+
153
+ export function stateFromJSON(text) {
154
+ if (typeof text !== 'string' || text === '') return null
155
+ try {
156
+ const value = JSON.parse(text)
157
+ return isValidState(value) ? value : null
158
+ } catch {
159
+ return null
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Advance a running state to the phase that should be active at `now`.
165
+ * Expired boundaries retain their original timestamps, so reopening after a
166
+ * long sleep lands on the correct wall-clock phase rather than restarting.
167
+ */
168
+ export function settleState(state, config, now) {
169
+ if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
170
+ const valid = parseConfig(config)
171
+ assertFinite(now, 'now')
172
+ if (!state.running || now < state.endsAt) return state
173
+
174
+ const next = { ...state, updatedAt: now }
175
+ let transitions = 0
176
+ const cycleMs = (valid.focusMinutes + valid.breakMinutes) * MINUTE_MS
177
+
178
+ while (now >= next.endsAt) {
179
+ const boundary = next.endsAt
180
+ next.phase = otherPhase(next.phase)
181
+ next.startedAt = boundary
182
+ next.durationMs = (next.phase === FOCUS ? valid.focusMinutes : valid.breakMinutes) * MINUTE_MS
183
+ next.endsAt = boundary + next.durationMs
184
+ next.remainingMs = null
185
+ next.running = true
186
+ transitions += 1
187
+
188
+ // After two transitions both durations come from the current config.
189
+ // Skip complete cycles arithmetically instead of iterating once per minute.
190
+ if (transitions >= 2) {
191
+ const cycles = Math.floor((now - next.startedAt) / cycleMs)
192
+ if (cycles > 0) {
193
+ next.startedAt += cycles * cycleMs
194
+ next.endsAt += cycles * cycleMs
195
+ transitions += cycles * 2
196
+ }
197
+ }
198
+ }
199
+
200
+ return next
201
+ }
202
+
203
+ export function remainingAt(state, now) {
204
+ if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
205
+ assertFinite(now, 'now')
206
+ if (!state.running) return state.remainingMs
207
+ // A backwards wall-clock adjustment must not show more than a full phase.
208
+ return Math.min(state.durationMs, Math.max(0, state.endsAt - now))
209
+ }
210
+
211
+ export function pauseState(state, config, now) {
212
+ const settled = settleState(state, config, now)
213
+ if (!settled.running) return settled
214
+ return {
215
+ ...settled,
216
+ running: false,
217
+ endsAt: null,
218
+ remainingMs: remainingAt(settled, now),
219
+ updatedAt: now,
220
+ }
221
+ }
222
+
223
+ export function resumeState(state, config, now) {
224
+ if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
225
+ assertFinite(now, 'now')
226
+ if (state.running) return state
227
+ const elapsed = Math.max(0, state.durationMs - state.remainingMs)
228
+ const resumed = {
229
+ ...state,
230
+ running: true,
231
+ startedAt: now - elapsed,
232
+ endsAt: now + state.remainingMs,
233
+ remainingMs: null,
234
+ updatedAt: now,
235
+ }
236
+ return settleState(resumed, config, now)
237
+ }
238
+
239
+ export function skipState(state, config, now) {
240
+ if (!isValidState(state)) throw new TypeError('Invalid Pomodoro state')
241
+ return createRunningState(otherPhase(state.phase), config, now)
242
+ }
243
+
244
+ export function resetState(config, now) {
245
+ return createRunningState(FOCUS, config, now)
246
+ }
247
+
248
+ export function formatRemaining(milliseconds) {
249
+ assertFinite(milliseconds, 'milliseconds')
250
+ const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000))
251
+ const minutes = Math.floor(totalSeconds / 60)
252
+ const seconds = totalSeconds % 60
253
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
254
+ }