dsh-offwork 1.1.1 → 1.2.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +48 -26
  3. package/ROADMAP.md +70 -0
  4. package/client.js +5066 -1099
  5. package/client.source.js +1988 -541
  6. package/companion-app.js +1431 -0
  7. package/companion-image.js +59 -0
  8. package/companion-state.js +424 -0
  9. package/companion.js +6 -9
  10. package/countdown.js +83 -0
  11. package/docs/RELEASE-1.2.2.md +20 -0
  12. package/hydration-store.js +55 -16
  13. package/hydration.js +231 -250
  14. package/icon.svg +28 -1
  15. package/index.js +59 -6
  16. package/locale/en.json +1 -1
  17. package/locale/zh.json +1 -1
  18. package/media-manifest.js +29 -0
  19. package/media-server.js +123 -0
  20. package/package.json +13 -3
  21. package/pet-interactions.js +121 -17
  22. package/pet-motion.js +2 -1
  23. package/pet-reminder.js +20 -0
  24. package/pet-surfaces.js +130 -6
  25. package/pet-video.js +221 -72
  26. package/pet-view.js +71 -4
  27. package/settings-api.js +126 -0
  28. package/tests/classic-overview.test.mjs +77 -0
  29. package/tests/companion-state.test.mjs +267 -0
  30. package/tests/companion.test.mjs +12 -12
  31. package/tests/countdown.test.mjs +115 -0
  32. package/tests/host.test.mjs +26 -13
  33. package/tests/hydration-extras.test.mjs +168 -204
  34. package/tests/hydration-store.test.mjs +199 -213
  35. package/tests/hydration.test.mjs +116 -186
  36. package/tests/media-server.test.mjs +179 -0
  37. package/tests/pet-interactions.test.mjs +19 -0
  38. package/tests/pet-reminder.test.mjs +71 -0
  39. package/tests/pet-video.test.mjs +171 -5
  40. package/tests/settings-api.test.mjs +130 -0
  41. package/tests/ui.test.mjs +4 -1
  42. package/tools/build.mjs +45 -16
  43. package/tools/check-package.mjs +12 -0
  44. package/ui-utils.js +44 -0
package/client.source.js CHANGED
@@ -13,7 +13,42 @@ window.__ModuleLoader__.load({
13
13
  ...PET_MESSAGES.zh,
14
14
  ...PET_INTERACTION_MESSAGES.zh,
15
15
  ...PET_SURFACE_MESSAGES.zh,
16
+ detailHelp: '使用说明',
17
+ detailApply: '应用',
18
+ detailShift: '班次详情',
19
+ detailWaterRule: '全天按自然时间提醒,需保持页面打开。',
20
+ detailSavedInterval: '已生效间隔',
21
+ detailDraftHint: '保存后生效',
22
+ detailNextInterval: '用于下一轮计时',
16
23
  title: '下班计时器',
24
+ classicMiniWorking: '距下班',
25
+ classicMiniBefore: '距开工',
26
+ classicMiniLunch: '午休中',
27
+ classicMiniStatus: '本班状态',
28
+ classicMiniWaiting: '待开启',
29
+ classicMiniFinished: '已结束',
30
+ classicOverview: '今日概览',
31
+ classicReturn: '返回概览',
32
+ classicMore: '展开作息详情',
33
+ classicLess: '收起作息详情',
34
+ classicNoSession: '等待会话',
35
+ classicUnavailable: '暂时无法计时',
36
+ classicRetryHint: '重新读取作息后恢复计时。',
37
+ classicLoading: '正在准备计时',
38
+ classicLoadingHint: '正在读取已保存的作息。',
39
+ classicBeforeHint: '班次尚未开始,显示距离开工的时间。',
40
+ classicWorkingHint: '本班工作进行中。',
41
+ classicLunchRemaining: '距离午休结束',
42
+ classicLunchHint: '午休结束后继续工作;喝水提醒照常计时。',
43
+ classicDoneHint: '本班计时已结束,下一班开始后继续。',
44
+ classicRestHint: '今天不计工作时长,计时会在工作日恢复。',
45
+ classicWaterHint: '自然时间计时',
46
+ classicSmokeHint: '自然时间计时',
47
+ classicConfigure: '设置作息与薪资',
48
+ classicIncome: '本班收入',
49
+ classicIncomeEntry: '查看收入详情',
50
+ classicShift: '本班作息',
51
+ classicEnd: '下班时间',
17
52
  working: '距离下班',
18
53
  before: '距离开工',
19
54
  lunch: '午休中',
@@ -48,7 +83,7 @@ window.__ModuleLoader__.load({
48
83
  error: '操作失败,请重试。',
49
84
  saved: '设置已保存',
50
85
  days: '日一二三四五六',
51
- privacy: '配置保存在 Harness 所在机器;读写结果进入所选会话记录。',
86
+ privacy: '配置保存在 Harness 所在机器;界面直接保存,不写入会话。代理访问默认关闭。',
52
87
  drag: '按住可拖动;方向键微调,Home 复位',
53
88
  setup: '先设好你的作息',
54
89
  setupHint: '示例数值不是你的收入,保存后才开始计时。',
@@ -74,7 +109,7 @@ window.__ModuleLoader__.load({
74
109
  opacityHint: '只影响背景,不影响文字。',
75
110
  waterTitle: '喝水提醒',
76
111
  waterEnable: '开启喝水提醒',
77
- waterInterval: '自定义间隔(分钟)',
112
+ waterInterval: '提醒间隔(分钟)',
78
113
  waterSave: '保存提醒设置',
79
114
  waterEdit: '设置喝水提醒',
80
115
  waterBack: '返回',
@@ -84,21 +119,21 @@ window.__ModuleLoader__.load({
84
119
  waterEffective: '当前间隔',
85
120
  waterManage: '数据管理',
86
121
  waterSaveHint: '修改后点击保存,按新间隔重新计时。',
87
- waterBrief: '按工作时间计时,午休暂停。关闭页面后不提醒。',
122
+ waterBrief: '按自然时间计时,午休、下班和休息日均可提醒。需要保持页面打开。',
88
123
  waterApply: '应用间隔',
89
124
  waterInvalid: '请输入 5–240 的整数。',
90
125
  waterNext: '下次喝水',
91
126
  waterDue: '该喝水啦',
92
127
  waterDrink: '喝了',
93
- waterSnooze: '工作 10 分钟后',
94
- waterPause: '本班暂停',
95
- waterResume: '恢复本班提醒',
96
- waterCount: '本班打卡',
128
+ waterSnooze: '10 分钟后',
129
+ waterPause: '暂停提醒',
130
+ waterResume: '恢复提醒',
131
+ waterCount: '饮水计数',
97
132
  waterManual: '喝了一杯',
98
133
  waterQuick: '喝水快捷操作',
99
134
  waterQuickClose: '收起喝水快捷操作',
100
135
  waterRecorded: '这杯记下啦。',
101
- waterRecordHint: '工作和午休期间可以打卡,关闭提醒也能记录。',
136
+ waterRecordHint: '随时可以打卡,关闭提醒也能记录;计数可手动清零。',
102
137
  waterActionStale: '状态已变化,请再试一次。',
103
138
  waterQuiet: '免打扰',
104
139
  waterQuietAdjust: '调整时长',
@@ -109,32 +144,68 @@ window.__ModuleLoader__.load({
109
144
  waterQuietEnded: '免打扰已结束。',
110
145
  waterQuietHint: '按自然时间,到期自动恢复;已到期的提醒只提示一次。',
111
146
  waterOff: '提醒已关闭',
112
- waterWaiting: '等待工作时段',
113
- waterBreak: '休息中,提醒计时暂停',
114
- waterPaused: '本班已暂停',
115
- waterFinished: '本班不再安排新提醒',
147
+ waterWaiting: '等待开启提醒',
148
+ waterBreak: '提醒已暂停',
149
+ waterPaused: '提醒已暂停',
150
+ waterFinished: '提醒已结束',
116
151
  waterOther: '提醒已到期,等待可见标签页处理',
117
- waterClear: '清除本班次数',
152
+ waterClear: '清除饮水计数',
118
153
  waterReset: '重置喝水提醒',
119
154
  waterConfirm: '确认操作',
120
- waterClearHint: '仅清零本班次数,保留下一次提醒和暂停状态。',
121
- waterResetHint: '清除喝水提醒设置和记录,恢复默认关闭。',
155
+ waterClearHint: '仅清零饮水计数,保留下一次提醒和暂停状态。',
156
+ waterResetHint: '清除当前喝水提醒设置和计数,恢复默认关闭。',
122
157
  waterLocal: '提醒设置和打卡仅保存在当前浏览器,不进入会话。与薪资配置分别保存。',
123
- waterRules: '按工作分钟计时,午休冻结。开启、修改间隔或作息后重新计时;关闭页面后不提醒。',
158
+ waterRules:
159
+ '按自然分钟计时,与作息、薪资和会话无关。开启、修改间隔或手动恢复后重新计时;关闭页面后不提醒。',
124
160
  waterUnavailable:
125
- '喝水提醒存储不可用或数据无效,自动提醒已停止。可重试;重置将清除原喝水数据。',
161
+ '喝水提醒存储不可用或数据无效,自动提醒已停止。可重试;重置将清除当前提醒设置和计数。',
126
162
  waterRetry: '重试读取',
127
- waterSetup: '请先保存薪资与作息。',
163
+ waterSetup: '开启后按所选间隔提醒。',
128
164
  waterLoading: '正在读取喝水提醒…',
129
- waterChanged: '提醒设置已在本浏览器生效',
130
- waterStale: '作息已变化,请点击“读取最新配置”后继续提醒。',
165
+ waterChanged: '提醒设置已保存',
166
+ waterStale: '记录已更新,请重试读取。',
131
167
  },
132
168
  en: {
133
169
  ...COMPANION_MESSAGES.en,
134
170
  ...PET_MESSAGES.en,
135
171
  ...PET_INTERACTION_MESSAGES.en,
136
172
  ...PET_SURFACE_MESSAGES.en,
173
+ detailHelp: 'How it works',
174
+ detailApply: 'Apply',
175
+ detailShift: 'Shift details',
176
+ detailWaterRule: 'Reminds you throughout the day. Keep the page open.',
177
+ detailSavedInterval: 'Saved interval',
178
+ detailDraftHint: 'Applies when saved',
179
+ detailNextInterval: 'Applies to the next timer',
137
180
  title: 'Offwork Clock',
181
+ classicMiniWorking: 'To end',
182
+ classicMiniBefore: 'To start',
183
+ classicMiniLunch: 'Break',
184
+ classicMiniStatus: 'Status',
185
+ classicMiniWaiting: 'Waiting',
186
+ classicMiniFinished: 'Done',
187
+ classicOverview: 'Today at a glance',
188
+ classicReturn: 'Back to overview',
189
+ classicMore: 'Show schedule details',
190
+ classicLess: 'Hide schedule details',
191
+ classicNoSession: 'Waiting for a session',
192
+ classicUnavailable: 'Timer unavailable',
193
+ classicRetryHint: 'Reload your schedule to restore the timer.',
194
+ classicLoading: 'Preparing your timer',
195
+ classicLoadingHint: 'Loading your saved schedule.',
196
+ classicBeforeHint: 'Your shift has not started. This is the time until work begins.',
197
+ classicWorkingHint: 'Your shift is in progress.',
198
+ classicLunchRemaining: 'Until lunch ends',
199
+ classicLunchHint: 'Work resumes after lunch. Water reminders continue as usual.',
200
+ classicDoneHint: 'This shift has ended. Timing resumes with your next shift.',
201
+ classicRestHint: 'No work time is counted today. Timing resumes on a scheduled workday.',
202
+ classicWaterHint: 'Clock time',
203
+ classicSmokeHint: 'Clock time',
204
+ classicConfigure: 'Set schedule and pay',
205
+ classicIncome: 'Shift earnings',
206
+ classicIncomeEntry: 'View earnings',
207
+ classicShift: 'Shift schedule',
208
+ classicEnd: 'Shift ends',
138
209
  working: 'Until clock-out',
139
210
  before: 'Until work starts',
140
211
  lunch: 'On break',
@@ -169,7 +240,8 @@ window.__ModuleLoader__.load({
169
240
  error: 'Operation failed. Please retry.',
170
241
  saved: 'Settings saved',
171
242
  days: 'SMTWTFS',
172
- privacy: 'Settings are saved on the Harness host; results enter the selected session log.',
243
+ privacy:
244
+ 'Settings stay on the Harness host. UI saves directly without conversation logs; agent access is off by default.',
173
245
  drag: 'Drag to move; arrow keys to adjust; Home resets',
174
246
  setup: 'Set up your schedule',
175
247
  setupHint: 'These are example values, not your income. Save to start.',
@@ -196,7 +268,7 @@ window.__ModuleLoader__.load({
196
268
  opacityHint: 'Affects the background only.',
197
269
  waterTitle: 'Water reminder',
198
270
  waterEnable: 'Enable water reminders',
199
- waterInterval: 'Custom interval (minutes)',
271
+ waterInterval: 'Interval (minutes)',
200
272
  waterSave: 'Save reminder settings',
201
273
  waterEdit: 'Reminder settings',
202
274
  waterBack: 'Back',
@@ -206,21 +278,23 @@ window.__ModuleLoader__.load({
206
278
  waterEffective: 'Current interval',
207
279
  waterManage: 'Manage data',
208
280
  waterSaveHint: 'Save changes to restart with the new interval.',
209
- waterBrief: 'Counts working time and pauses on unpaid breaks. Keep the page open.',
281
+ waterBrief:
282
+ 'Uses clock time, including breaks, off-hours and days off. Keep the page open.',
210
283
  waterApply: 'Apply interval',
211
284
  waterInvalid: 'Enter a whole number from 5 to 240.',
212
285
  waterNext: 'Next water break',
213
286
  waterDue: 'Time for some water',
214
287
  waterDrink: 'Drank water',
215
- waterSnooze: 'In 10 working minutes',
216
- waterPause: 'Pause this shift',
217
- waterResume: 'Resume this shift',
218
- waterCount: 'Drinks this shift',
288
+ waterSnooze: 'In 10 minutes',
289
+ waterPause: 'Pause reminders',
290
+ waterResume: 'Resume reminders',
291
+ waterCount: 'Drinks recorded',
219
292
  waterManual: 'Had a glass',
220
293
  waterQuick: 'Quick water actions',
221
294
  waterQuickClose: 'Close quick water actions',
222
295
  waterRecorded: 'Got it, your drink is recorded.',
223
- waterRecordHint: 'Check in during work or breaks, even with reminders off.',
296
+ waterRecordHint:
297
+ 'Check in at any time, even with reminders off. Clear the count manually when needed.',
224
298
  waterActionStale: 'The state changed. Please try again.',
225
299
  waterQuiet: 'Quiet time',
226
300
  waterQuietAdjust: 'Change duration',
@@ -232,32 +306,32 @@ window.__ModuleLoader__.load({
232
306
  waterQuietHint:
233
307
  'Uses clock time. Resumes automatically, with just one reminder if overdue.',
234
308
  waterOff: 'Reminders off',
235
- waterWaiting: 'Waiting for working hours',
236
- waterBreak: 'On break; reminder timer paused',
237
- waterPaused: 'Paused for this shift',
238
- waterFinished: 'No more reminders this shift',
309
+ waterWaiting: 'Waiting for reminders to be enabled',
310
+ waterBreak: 'Reminders paused',
311
+ waterPaused: 'Reminders paused',
312
+ waterFinished: 'Reminders ended',
239
313
  waterOther: 'Reminder due; waiting for a visible tab',
240
- waterClear: 'Clear shift count',
314
+ waterClear: 'Clear drink count',
241
315
  waterReset: 'Reset water reminders',
242
316
  waterConfirm: 'Confirm',
243
317
  waterClearHint: 'Clear the drink count only. Keep the next reminder and pause state.',
244
- waterResetHint: 'Delete water preferences and records, then turn reminders off.',
318
+ waterResetHint: 'Clear current water preferences and count, then turn reminders off.',
245
319
  waterLocal:
246
320
  'Reminder preferences and check-ins stay in this browser, outside conversations. Saved separately from salary settings.',
247
321
  waterRules:
248
- 'Counts working minutes; unpaid breaks pause the timer. Enabling or changing interval or schedule restarts it. The page must stay open.',
322
+ 'Uses clock time, independently of work, salary and sessions. Enabling, changing the interval or manually resuming starts a new interval. Keep the page open.',
249
323
  waterUnavailable:
250
- 'Water storage is unavailable or invalid; automatic reminders stopped. Retry, or reset to delete old water data.',
324
+ 'Water storage is unavailable or invalid; automatic reminders stopped. Retry, or reset current water preferences and count.',
251
325
  waterRetry: 'Retry loading',
252
- waterSetup: 'Save your salary and schedule first.',
326
+ waterSetup: 'Enable reminders to start the selected interval.',
253
327
  waterLoading: 'Loading water reminders…',
254
- waterChanged: 'Reminder preferences saved in this browser',
255
- waterStale: 'Schedule changed. Reload the latest settings to continue reminders.',
328
+ waterChanged: 'Reminder settings saved',
329
+ waterStale: 'Records changed. Retry loading to continue.',
256
330
  },
257
331
  };
258
332
  const STYLE =
259
333
  `
260
- .owc-v2{--owc-accent:color-mix(in srgb,var(--dsw-alias-brand-primary) 65%,#2563eb);pointer-events:auto;position:fixed;top:96px;right:24px;z-index:40;max-width:calc(100vw - 24px);max-height:calc(100dvh - 24px);border-radius:20px;color:var(--dsw-alias-label-primary);font:13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif;cursor:grab;touch-action:manipulation;user-select:none;-webkit-user-select:none}
334
+ .owc-v2{--owc-accent:color-mix(in srgb,var(--dsw-alias-brand-primary) 65%,#2563eb);pointer-events:auto;position:fixed;top:96px;right:24px;z-index:40;max-width:calc(100vw - 24px);max-height:calc(100dvh - 24px);border-radius:20px;color:var(--dsw-alias-label-primary);font:var(--owc-font-size,13px)/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif;cursor:grab;touch-action:manipulation;user-select:none;-webkit-user-select:none}
261
335
  .owc-v2:active{cursor:grabbing}
262
336
  .owc-v2.owc-compact{touch-action:none;border-radius:18px}
263
337
  .owc-v2.owc-compact.owc-quick{width:296px;touch-action:manipulation}
@@ -384,6 +458,15 @@ window.__ModuleLoader__.load({
384
458
  water: [
385
459
  ['path', { d: 'M8 1.5C6.6 4 3.5 7.1 3.5 10a4.5 4.5 0 0 0 9 0C12.5 7.1 9.4 4 8 1.5Z' }],
386
460
  ],
461
+ smoking: [
462
+ ['rect', { x: 1.5, y: 10, width: 11, height: 3, rx: 0.5 }],
463
+ [
464
+ 'path',
465
+ {
466
+ d: 'M9.5 10v3M14.5 10v3M10 7.5c1-1 .8-1.8 0-2.5s-1-1.5 0-2.5M13 6c1-1 .8-1.8 0-2.5s-1-1.5 0-2.5',
467
+ },
468
+ ],
469
+ ],
387
470
  eye: [
388
471
  ['path', { d: 'M1.7 8S4.3 3.9 8 3.9 14.3 8 14.3 8 11.7 12.1 8 12.1 1.7 8 1.7 8Z' }],
389
472
  ['circle', { cx: 8, cy: 8, r: 2.05 }],
@@ -410,6 +493,19 @@ window.__ModuleLoader__.load({
410
493
  ['path', { d: 'M13.1 3v3.2H9.9' }],
411
494
  ],
412
495
  };
496
+ const LOCAL_SCREENS = [
497
+ null,
498
+ 'appearance',
499
+ 'smoking',
500
+ 'water-countdown',
501
+ 'water',
502
+ 'home',
503
+ 'income',
504
+ 'wrap',
505
+ 'preferences',
506
+ 'data',
507
+ 'reminders',
508
+ ];
413
509
  const iconSvg = (name, size = 15) =>
414
510
  h(
415
511
  'svg',
@@ -457,12 +553,20 @@ window.__ModuleLoader__.load({
457
553
  const waterEditing = ui.screen === 'water';
458
554
  const [appearanceDraft, setAppearanceDraft] = React.useState(null);
459
555
  const appearanceBase = React.useRef(null);
556
+ const previewBox = React.useRef(null);
557
+ const previewMotion = React.useRef(null);
558
+ const [previewState, setPreviewState] = React.useState({ phase: 'idle', action: null });
559
+ const [previewReduced, setPreviewReduced] = React.useState(
560
+ () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
561
+ );
460
562
  const appearanceWasPet = React.useRef(false);
461
563
  const salaryBase = React.useRef(null);
462
564
  const menuBox = React.useRef(null);
463
565
  const menuTrigger = React.useRef(null);
464
566
  const [dragPosition, setDragPosition] = React.useState(null);
465
- const [busy, setBusy] = React.useState(false);
567
+ const requestBusy = React.useRef(false);
568
+ const [hostBusy, setBusy] = React.useState(false);
569
+ const busy = hostBusy && (requestBusy.current || !LOCAL_SCREENS.includes(ui.screen));
466
570
  const [error, setError] = React.useState('');
467
571
  const [notice, setNotice] = React.useState('');
468
572
  const [storageError, setStorageError] = React.useState(false);
@@ -488,8 +592,118 @@ window.__ModuleLoader__.load({
488
592
  const dragState = React.useRef(null);
489
593
  const lastPosition = React.useRef(null);
490
594
  const generation = React.useRef(0);
491
- const requestBusy = React.useRef(false);
492
595
  const focusReturn = React.useRef(null);
596
+ const [smoking, setSmoking] = React.useState(defaultCountdown);
597
+ const [smokingReady, setSmokingReady] = React.useState(false);
598
+ const [smokingBusy, setSmokingBusy] = React.useState(false);
599
+ const [smokingError, setSmokingError] = React.useState('');
600
+ const smokingRuntime = React.useRef(null);
601
+ React.useEffect(() => {
602
+ let alive = true,
603
+ reading = false,
604
+ writing = false,
605
+ issued = 0,
606
+ accepted = 0,
607
+ channel;
608
+ const makeStore = () => {
609
+ try {
610
+ return createStateStore(
611
+ window.indexedDB,
612
+ SMOKING_TIMER_DB,
613
+ defaultCountdown,
614
+ validateCountdown,
615
+ );
616
+ } catch {
617
+ return createStateStore(
618
+ null,
619
+ SMOKING_TIMER_DB,
620
+ defaultCountdown,
621
+ validateCountdown,
622
+ );
623
+ }
624
+ };
625
+ let store = makeStore();
626
+ const accept = (next, order) => {
627
+ if (!alive || order < accepted) return;
628
+ accepted = order;
629
+ setSmoking(next);
630
+ setNow(Date.now());
631
+ setSmokingReady(true);
632
+ };
633
+ const refresh = async () => {
634
+ if (!alive || reading || writing) return;
635
+ reading = true;
636
+ const source = store,
637
+ order = ++issued;
638
+ try {
639
+ const next = await source.update((current) => current);
640
+ if (source === store) accept(next, order);
641
+ } catch {
642
+ if (alive && source === store && order >= accepted)
643
+ setSmokingError('timerStorageError');
644
+ } finally {
645
+ reading = false;
646
+ }
647
+ };
648
+ const run = async (action) => {
649
+ if (!alive || writing) return;
650
+ writing = true;
651
+ const order = ++issued;
652
+ setSmokingBusy(true);
653
+ try {
654
+ if (action.type === 'retry') {
655
+ store.close();
656
+ store = makeStore();
657
+ }
658
+ let changedElsewhere = false;
659
+ const next = await store.update((current) => {
660
+ if (action.type === 'retry') return current;
661
+ if (current.revision !== action.revision) {
662
+ changedElsewhere = true;
663
+ return current;
664
+ }
665
+ return transitionCountdown(current, action, Date.now());
666
+ });
667
+ accept(next, order);
668
+ if (alive) setSmokingError(changedElsewhere ? 'timerChanged' : '');
669
+ try {
670
+ channel?.postMessage(next.revision);
671
+ } catch {
672
+ /* Polling also synchronizes. */
673
+ }
674
+ return changedElsewhere ? null : next;
675
+ } catch {
676
+ if (alive) setSmokingError('timerStorageError');
677
+ } finally {
678
+ writing = false;
679
+ if (alive) setSmokingBusy(false);
680
+ }
681
+ };
682
+ smokingRuntime.current = { run };
683
+ try {
684
+ channel = new BroadcastChannel(SMOKING_TIMER_DB);
685
+ channel.onmessage = refresh;
686
+ } catch {
687
+ /* Polling is sufficient. */
688
+ }
689
+ void refresh();
690
+ const timer = window.setInterval(refresh, 2000);
691
+ window.addEventListener('focus', refresh);
692
+ document.addEventListener('visibilitychange', refresh);
693
+ return () => {
694
+ alive = false;
695
+ smokingRuntime.current = null;
696
+ window.clearInterval(timer);
697
+ window.removeEventListener('focus', refresh);
698
+ document.removeEventListener('visibilitychange', refresh);
699
+ channel?.close();
700
+ store.close();
701
+ };
702
+ }, []);
703
+ const smokingState = countdownView(smoking, now);
704
+ const smokingUnavailable = !smokingReady || smokingError === 'timerStorageError';
705
+ const smokingAction = (action) =>
706
+ smokingRuntime.current?.run({ ...action, revision: smoking.revision });
493
707
  const [water, setWater] = React.useState(defaultHydration);
494
708
  const [waterReady, setWaterReady] = React.useState(false);
495
709
  const [waterError, setWaterError] = React.useState(false);
@@ -507,6 +721,7 @@ window.__ModuleLoader__.load({
507
721
  const [waterInvalid, setWaterInvalid] = React.useState(false);
508
722
  const [waterConfirm, setWaterConfirm] = React.useState(null);
509
723
  const [waterNotice, setWaterNotice] = React.useState(false);
724
+ const [reminderGate, setReminderGate] = React.useState(initialPetReminder);
510
725
  const reactionState = React.useRef(null);
511
726
  const petMotion = React.useRef(null);
512
727
  const petReactionToken = React.useRef(null);
@@ -516,32 +731,90 @@ window.__ModuleLoader__.load({
516
731
  waterOwner.current =
517
732
  window.crypto?.randomUUID?.() || String(Math.random()) + Date.now();
518
733
  const waterInputs = React.useRef(null);
519
- const waterScheduleChange = React.useRef(null);
520
- waterInputs.current = {
521
- settings: data?.configured && !error ? data.settings : null,
522
- editing: editing || waterEditing,
523
- };
734
+ waterInputs.current = { editing: editing || waterEditing };
524
735
 
525
736
  const settings = data?.settings;
526
737
  const workingDraft = draft || settings;
527
738
  const state = settings ? safeCalculate(settings) : null;
528
739
  const preview = workingDraft ? safeCalculate(workingDraft) : null;
529
740
  const safeState = state && !state.error ? state : null;
530
- const petMode =
531
- ready &&
532
- data?.configured &&
533
- !!safeState &&
534
- (appearanceOnly ? appearanceWasPet.current : view.companion);
535
- const stageModel = appearanceOnly ? savedView.petModel : view.petModel;
741
+ const features = useCompanionFeatures(React, {
742
+ language: locale.active,
743
+ screen: ui.screen,
744
+ menuOpen: !!ui.menu,
745
+ navigate: (screen, event) => navigateScreen(screen, event),
746
+ enableCharacter: () => updateAppearance({ companion: true }),
747
+ configured: !!data?.configured,
748
+ shift: data?.configured ? safeState : null,
749
+ timezone: settings?.timezone,
750
+ smoking,
751
+ smokingReady: smokingReady && !smokingError,
752
+ water,
753
+ waterReady: waterReady && !waterError,
754
+ waterOwner: waterOwner.current,
755
+ smokingLabel: t('smokingCountdown'),
756
+ waterLabel: t('waterDue'),
757
+ wrapLabel: t('companionDone'),
758
+ editing: editing || waterEditing || ui.screen === 'wrap' || !!ui.pending,
759
+ sessionIds,
760
+ sessionTitles: Object.fromEntries(
761
+ sessionIds.map((id) => [id, sessions?.byId?.[id]?.title]),
762
+ ),
763
+ runningSessions: sessionIds.filter((id) => sessions?.byId?.[id]?.running === true),
764
+ view: savedView,
765
+ setView: (patch) => setPreference(patch),
766
+ openLegacy: (screen) => navigateScreen(screen),
767
+ reload,
768
+ refreshLegacy: () => {
769
+ waterRuntime.current?.tick();
770
+ smokingRuntime.current?.run({ type: 'retry' });
771
+ },
772
+ restartSmoking: () => smokingAction({ type: 'restart' }),
773
+ drink: () => waterAction({ type: 'manual-drink', actionId: crypto.randomUUID() }),
774
+ react: () => petMotion.current?.play('greet'),
775
+ });
776
+ const displayPreferences =
777
+ appearanceOnly && features.previewPreferences
778
+ ? features.previewPreferences
779
+ : features.state.preferences;
780
+ const featureStyle = {
781
+ ...(COMPANION_THEMES[displayPreferences.theme]
782
+ ? { '--owc-accent': COMPANION_THEMES[displayPreferences.theme] }
783
+ : {}),
784
+ '--owc-font-size': 13 * displayPreferences.cardScale + 'px',
785
+ };
786
+ const petMode = ready && (appearanceOnly ? appearanceWasPet.current : view.companion);
787
+ const petClockStatus =
788
+ error || state?.error
789
+ ? 'petLoadFailed'
790
+ : !data
791
+ ? 'loading'
792
+ : !data.configured
793
+ ? 'petNeedsSetup'
794
+ : safeState?.status || 'loading';
795
+ const stageModel = features.customImage
796
+ ? 'custom-image'
797
+ : appearanceOnly
798
+ ? savedView.petModel
799
+ : view.petModel;
536
800
  const petGeometry = petLayout(
537
801
  dragPosition ?? view.petPosition,
538
802
  viewport,
539
803
  appearanceOnly ? savedView.petSize : view.petSize,
540
804
  420,
541
805
  stageModel,
806
+ (window.matchMedia?.('(pointer:coarse)').matches ? 144 : 108) +
807
+ (features.activeTimer ? 30 : 0),
808
+ 196,
542
809
  );
543
810
  const surfaceScreen = ui.screen || 'reminder';
544
- const surfaceWidth = petSurfaceWidth(surfaceScreen, viewport);
811
+ const surfaceWidth = Math.max(
812
+ 1,
813
+ Math.min(
814
+ petSurfaceWidth(surfaceScreen, viewport) * displayPreferences.cardScale,
815
+ viewport.width - 24,
816
+ ),
817
+ );
545
818
  const measureKey = [surfaceScreen, surfaceWidth, locale.active].join(':');
546
819
  const measured = cardMeasure.key === measureKey;
547
820
  const naturalHeight = measured ? cardMeasure.height : 320;
@@ -582,7 +855,7 @@ window.__ModuleLoader__.load({
582
855
  );
583
856
  const waterAlertLayout = placePetSurface(anchor, viewport, {
584
857
  width: Math.min(140, Math.max(110, alertRoom)),
585
- height: 34,
858
+ height: 44,
586
859
  kind: 'menu',
587
860
  obstacles: [...(petOpen ? [floatingPanel] : []), ...(ui.menu ? [menuLayout] : [])],
588
861
  });
@@ -591,14 +864,15 @@ window.__ModuleLoader__.load({
591
864
  height: toastHeight,
592
865
  kind: 'menu',
593
866
  });
594
- const collapsed =
595
- !petMode &&
596
- !!safeState &&
597
- !editing &&
598
- !waterEditing &&
599
- view.mini &&
600
- !error &&
601
- !state?.error;
867
+ const collapsed = !petMode && ui.screen === null && view.mini;
868
+ const classicClock = classicClockPresentation({
869
+ hasSession: true,
870
+ loaded: !!data,
871
+ configured: !!data?.configured,
872
+ failed: !!error || !!state?.error,
873
+ state: safeState,
874
+ now,
875
+ });
602
876
  const seconds = safeState
603
877
  ? safeState.status === 'before'
604
878
  ? safeState.startRemaining
@@ -626,6 +900,16 @@ window.__ModuleLoader__.load({
626
900
  classicSize,
627
901
  );
628
902
  const cardStyle = {
903
+ ...featureStyle,
904
+ width: ui.screen
905
+ ? Math.min(
906
+ petSurfaceWidth(ui.screen, viewport) * displayPreferences.cardScale,
907
+ viewport.width - 24,
908
+ )
909
+ : Math.min(
910
+ (collapsed ? 224 : 320) * displayPreferences.cardScale,
911
+ Math.max(1, viewport.width - 24),
912
+ ),
629
913
  '--owc-alpha': String(view.opacity),
630
914
  '--owc-viewport-height': Math.max(1, viewport.height - 24) + 'px',
631
915
  left: classicPosition.x,
@@ -708,17 +992,8 @@ window.__ModuleLoader__.load({
708
992
  store = createHydrationStore(null);
709
993
  }
710
994
  try {
711
- channel = new BroadcastChannel('dsh.offwork-clock.hydration.v1');
995
+ channel = new BroadcastChannel('dsh.offwork-clock.hydration.wall-clock.v3');
712
996
  } catch {}
713
- const contextAt = (time) => {
714
- const current = waterInputs.current.settings;
715
- if (!current) return null;
716
- try {
717
- return hydrationContext(current, calculate(current, time));
718
- } catch {
719
- return null;
720
- }
721
- };
722
997
  const update = async (action = { type: 'tick' }, automatic = false) => {
723
998
  if (!active || (automatic && (running || failed))) return;
724
999
  running++;
@@ -734,59 +1009,31 @@ window.__ModuleLoader__.load({
734
1009
  let changed = false;
735
1010
  let recorded = false;
736
1011
  let quietChanged = false;
737
- let usedPermit = null;
738
1012
  const result = await store.update(
739
1013
  (current) => {
740
1014
  const time = Date.now();
741
- const context = contextAt(time);
742
1015
  if (action.type === 'sync') return current;
743
- const permit = waterScheduleChange.current;
744
- const operation =
745
- permit && context && permit.schedule === context.schedule
746
- ? { ...action, baseSchedule: permit.baseSchedule }
747
- : action;
748
- let next = transitionHydration(
749
- current,
750
- context,
751
- operation,
752
- time,
753
- waterOwner.current,
754
- );
1016
+ let next = transitionHydration(current, action, time, waterOwner.current);
755
1017
  recorded =
756
- ['drink', 'manual-drink'].includes(action.type) &&
757
- next.shift?.count >
758
- (current.shift?.id === next.shift?.id ? (current.shift?.count ?? 0) : 0);
1018
+ ['drink', 'manual-drink'].includes(action.type) && next.count > current.count;
759
1019
  quietChanged =
760
1020
  ['quiet', 'end-quiet'].includes(action.type) &&
761
- next.shift?.quietUntil !== current.shift?.quietUntil;
1021
+ next.quietUntil !== current.quietUntil;
762
1022
  if (automatic) {
763
1023
  const allow = !document.hidden && !waterInputs.current.editing;
764
1024
  next = transitionHydration(
765
1025
  next,
766
- context,
767
1026
  { type: allow ? 'claim' : 'release' },
768
1027
  time,
769
1028
  waterOwner.current,
770
1029
  );
771
1030
  }
772
- if (
773
- permit &&
774
- context &&
775
- permit.schedule === context.schedule &&
776
- next.shift?.schedule === context.schedule
777
- )
778
- usedPermit = permit;
779
1031
  changed = JSON.stringify(next) !== JSON.stringify(current);
780
1032
  return next;
781
1033
  },
782
1034
  { reset: action.type === 'reset' },
783
1035
  );
784
1036
  if (!active) return;
785
- if (
786
- action.type === 'reset' ||
787
- (usedPermit && waterScheduleChange.current === usedPermit)
788
- )
789
- waterScheduleChange.current = null;
790
1037
  failed = false;
791
1038
  setWater(result);
792
1039
  setWaterReady(true);
@@ -818,11 +1065,14 @@ window.__ModuleLoader__.load({
818
1065
  const visibility = () => {
819
1066
  setWaterVisible(!document.hidden);
820
1067
  setNow(Date.now());
821
- if (document.hidden) void update({ type: 'release' });
822
- else tick();
1068
+ if (document.hidden) {
1069
+ setReminderGate(initialPetReminder);
1070
+ void update({ type: 'release' });
1071
+ } else tick();
823
1072
  };
824
1073
  const pageHide = () => {
825
1074
  setWaterVisible(false);
1075
+ setReminderGate(initialPetReminder);
826
1076
  void update({ type: 'release' });
827
1077
  };
828
1078
  waterRuntime.current = { update, tick };
@@ -847,8 +1097,7 @@ window.__ModuleLoader__.load({
847
1097
  channel?.close();
848
1098
  void store
849
1099
  .update((current) => {
850
- if (current.shift?.claim?.owner === waterOwner.current)
851
- current.shift.claim = null;
1100
+ if (current.claim?.owner === waterOwner.current) current.claim = null;
852
1101
  return current;
853
1102
  })
854
1103
  .catch(() => {})
@@ -858,7 +1107,7 @@ window.__ModuleLoader__.load({
858
1107
 
859
1108
  React.useEffect(() => {
860
1109
  waterRuntime.current?.tick();
861
- }, [settings, editing, waterEditing, error]);
1110
+ }, [editing, waterEditing]);
862
1111
 
863
1112
  React.useEffect(() => {
864
1113
  if (!waterNotice) return;
@@ -904,6 +1153,7 @@ window.__ModuleLoader__.load({
904
1153
  const interval = Number(waterInterval);
905
1154
  if (!Number.isInteger(interval) || interval < 5 || interval > 240) {
906
1155
  setWaterInvalid(true);
1156
+ box.current?.querySelector('[name=waterInterval]')?.focus();
907
1157
  return;
908
1158
  }
909
1159
  const result = await waterAction({
@@ -928,6 +1178,13 @@ window.__ModuleLoader__.load({
928
1178
  const outcome = await waterRuntime.current?.update(action);
929
1179
  const result = outcome?.state;
930
1180
  if (result) {
1181
+ if (outcome.recorded)
1182
+ void features.record({
1183
+ id: 'water-record:' + result.revision,
1184
+ type: 'water',
1185
+ at: Date.now(),
1186
+ label: 'Water',
1187
+ });
931
1188
  setWaterConfirm(null);
932
1189
  setNow(Date.now());
933
1190
  if (action.type === 'preferences')
@@ -936,7 +1193,7 @@ window.__ModuleLoader__.load({
936
1193
  setWaterNotice({ key: 'waterRecorded', kind: 'drink', token: result.revision });
937
1194
  if (action.type === 'manual-drink') {
938
1195
  setWaterNotice({
939
- key: result.shift?.recentActions.includes(action.actionId)
1196
+ key: result.recentActions.includes(action.actionId)
940
1197
  ? 'waterRecorded'
941
1198
  : 'waterActionStale',
942
1199
  kind: outcome.recorded ? 'drink' : 'notice',
@@ -947,7 +1204,7 @@ window.__ModuleLoader__.load({
947
1204
  if (action.type === 'quiet')
948
1205
  setWaterNotice({
949
1206
  key:
950
- outcome.quietChanged && result.shift?.quietUntil > Date.now()
1207
+ outcome.quietChanged && result.quietUntil > Date.now()
951
1208
  ? 'waterQuietStarted'
952
1209
  : 'waterActionStale',
953
1210
  kind: 'notice',
@@ -975,13 +1232,9 @@ window.__ModuleLoader__.load({
975
1232
  ? '.owc-pet-handle'
976
1233
  : '.owc-grip,.owc-title'
977
1234
  : action.type === 'manual-drink'
978
- ? petMode
979
- ? '.owc-pet-water-row .owc-primary'
980
- : '.owc-water-actions .owc-primary'
1235
+ ? '.owc-water-record,.owc-water-actions .owc-primary'
981
1236
  : action.type === 'end-quiet'
982
- ? petMode
983
- ? '.owc-pet-quiet-row button'
984
- : '.owc-water-quiet summary'
1237
+ ? '.owc-detail-more summary,.owc-water-quiet summary'
985
1238
  : '.owc-water-form details > summary';
986
1239
  box.current?.querySelector(target)?.focus();
987
1240
  });
@@ -996,12 +1249,11 @@ window.__ModuleLoader__.load({
996
1249
  }
997
1250
 
998
1251
  React.useEffect(() => {
999
- if (sessionId) reload();
1000
- else setError(t('noSession'));
1252
+ reload();
1001
1253
  return () => {
1002
1254
  generation.current++;
1003
1255
  };
1004
- }, [sessionId]);
1256
+ }, []);
1005
1257
 
1006
1258
  React.useEffect(() => {
1007
1259
  let timer;
@@ -1050,7 +1302,7 @@ window.__ModuleLoader__.load({
1050
1302
  window.visualViewport?.removeEventListener('resize', resize);
1051
1303
  window.visualViewport?.removeEventListener('scroll', resize);
1052
1304
  };
1053
- }, [clamp, petMode]);
1305
+ }, [clamp, petMode, ready]);
1054
1306
 
1055
1307
  React.useEffect(() => {
1056
1308
  if (ui.pending) {
@@ -1087,13 +1339,19 @@ window.__ModuleLoader__.load({
1087
1339
  );
1088
1340
 
1089
1341
  async function request(action, patch) {
1090
- if (!sessionId) throw new Error(t('noSession'));
1091
- const rpc = await ctx.remote.commands.execute(
1092
- sessionId,
1093
- '/offwork-clock ' + action + (patch ? ' ' + JSON.stringify(patch) : ''),
1094
- [],
1095
- );
1096
- return decodeCommand(rpc);
1342
+ const response = await fetch(new URL('offwork-clock/settings', document.baseURI), {
1343
+ method: action === 'get' ? 'GET' : 'POST',
1344
+ credentials: 'same-origin',
1345
+ cache: 'no-store',
1346
+ headers: {
1347
+ 'X-Offwork-Client': '1',
1348
+ ...(action === 'get' ? {} : { 'Content-Type': 'application/json' }),
1349
+ },
1350
+ ...(action === 'get' ? {} : { body: JSON.stringify({ action, settings: patch }) }),
1351
+ });
1352
+ const result = await response.json();
1353
+ if (!response.ok || !result.ok) throw new Error(result.error || t('error'));
1354
+ return result;
1097
1355
  }
1098
1356
 
1099
1357
  async function reload(explicit = false) {
@@ -1102,21 +1360,11 @@ window.__ModuleLoader__.load({
1102
1360
  setBusy(true);
1103
1361
  setError('');
1104
1362
  try {
1105
- const baseSchedule = (await waterRuntime.current?.update({ type: 'sync' }))?.state
1106
- ?.shift?.schedule;
1107
- if (token !== generation.current) return;
1108
1363
  const next = await request('get');
1109
1364
  if (token !== generation.current) return;
1110
- if (next.configured)
1111
- waterScheduleChange.current = {
1112
- baseSchedule,
1113
- schedule: hydrationContext(next.settings, calculate(next.settings, Date.now()))
1114
- .schedule,
1115
- };
1116
1365
  setData(next);
1117
1366
  if (!next.configured) {
1118
1367
  setDraft({ ...next.settings });
1119
- dispatchUI({ type: 'navigate', screen: 'schedule', discard: true });
1120
1368
  salaryBase.current = next.settings;
1121
1369
  }
1122
1370
  if (explicit) setNotice(t('synced'));
@@ -1135,19 +1383,11 @@ window.__ModuleLoader__.load({
1135
1383
  setError('');
1136
1384
  const token = ++generation.current;
1137
1385
  try {
1138
- const baseSchedule = (await waterRuntime.current?.update({ type: 'sync' }))?.state
1139
- ?.shift?.schedule;
1140
- if (token !== generation.current) return;
1141
1386
  const next = await request(
1142
1387
  'set',
1143
1388
  changedSettings(salaryBase.current ?? settings, draft),
1144
1389
  );
1145
1390
  if (token === generation.current) {
1146
- waterScheduleChange.current = {
1147
- baseSchedule,
1148
- schedule: hydrationContext(next.settings, calculate(next.settings, Date.now()))
1149
- .schedule,
1150
- };
1151
1391
  setData(next);
1152
1392
  dispatchUI({ type: 'navigate', screen: ui.back, discard: true });
1153
1393
  setDraft(null);
@@ -1253,7 +1493,7 @@ window.__ModuleLoader__.load({
1253
1493
  petDragged.current = false;
1254
1494
  if (ui.pending) return true;
1255
1495
  if (!petMode) {
1256
- if (!busy && !waterBusy) navigateScreen('appearance', event);
1496
+ if (!requestBusy.current && !waterBusy) navigateScreen('appearance', event);
1257
1497
  return true;
1258
1498
  }
1259
1499
  const trigger =
@@ -1269,7 +1509,7 @@ window.__ModuleLoader__.load({
1269
1509
  : trigger;
1270
1510
  const rect = trigger?.getBoundingClientRect();
1271
1511
  const point =
1272
- event.type === 'keydown'
1512
+ event.type === 'keydown' || (event.type === 'click' && event.detail === 0)
1273
1513
  ? { x: rect?.right ?? petGeometry.x, y: rect?.top ?? petGeometry.y }
1274
1514
  : { x: event.clientX, y: event.clientY };
1275
1515
  dispatchUI({ type: 'menu', position: point });
@@ -1283,6 +1523,7 @@ window.__ModuleLoader__.load({
1283
1523
  function performNavigation(screen, root = false) {
1284
1524
  if (root) panelPlacement.current = null;
1285
1525
  if (screen !== ui.screen) {
1526
+ features.discardDrafts();
1286
1527
  if (screen === 'water') {
1287
1528
  waterPreferencesBase.current = { enabled: water.enabled, interval: water.interval };
1288
1529
  setWaterInterval(String(water.interval));
@@ -1296,6 +1537,7 @@ window.__ModuleLoader__.load({
1296
1537
  setError('');
1297
1538
  }
1298
1539
  if (screen === 'appearance') {
1540
+ features.beginAppearance();
1299
1541
  appearanceBase.current = appearanceValues(savedView);
1300
1542
  appearanceWasPet.current = !!petMode;
1301
1543
  setAppearanceDraft(appearanceValues(savedView));
@@ -1310,8 +1552,21 @@ window.__ModuleLoader__.load({
1310
1552
  setWaterConfirm(null);
1311
1553
  }
1312
1554
  function navigateScreen(screen, event) {
1313
- if (!data?.configured && screen !== 'schedule') return;
1314
- if (ui.pending || busy || waterBusy || requestBusy.current || waterPending.current)
1555
+ if (screen === 'water-countdown') screen = 'home';
1556
+ if (
1557
+ screen !== null &&
1558
+ !LOCAL_SCREENS.includes(screen) &&
1559
+ (!data || (!data.configured && screen !== 'schedule'))
1560
+ )
1561
+ return;
1562
+ if (
1563
+ ui.pending ||
1564
+ features.busy ||
1565
+ waterBusy ||
1566
+ requestBusy.current ||
1567
+ waterPending.current ||
1568
+ (hostBusy && !LOCAL_SCREENS.includes(screen))
1569
+ )
1315
1570
  return;
1316
1571
  const root = event?.currentTarget?.getAttribute?.('role') === 'menuitem';
1317
1572
  if (event?.currentTarget) focusReturn.current = event.currentTarget;
@@ -1322,6 +1577,7 @@ window.__ModuleLoader__.load({
1322
1577
  performNavigation(screen, root);
1323
1578
  }
1324
1579
  function draftChanged() {
1580
+ if (features.dirty) return true;
1325
1581
  if (waterEditing && waterPreferencesBase.current)
1326
1582
  return (
1327
1583
  waterEnabledDraft !== waterPreferencesBase.current.enabled ||
@@ -1335,7 +1591,8 @@ window.__ModuleLoader__.load({
1335
1591
  );
1336
1592
  return false;
1337
1593
  }
1338
- function applyAppearance() {
1594
+ async function applyAppearance() {
1595
+ if (!(await features.applyAppearance())) return false;
1339
1596
  panelPlacement.current = null;
1340
1597
  if (appearanceDraft)
1341
1598
  setPreference(changedSettings(appearanceBase.current, appearanceDraft));
@@ -1347,13 +1604,16 @@ window.__ModuleLoader__.load({
1347
1604
  const destination = ui.pending.screen;
1348
1605
  const result = waterEditing
1349
1606
  ? await saveWaterSettings({ preventDefault() {} })
1350
- : appearanceOnly
1351
- ? applyAppearance()
1352
- : await save({ preventDefault() {} });
1607
+ : ui.screen === 'wrap'
1608
+ ? await features.saveNotes(false)
1609
+ : appearanceOnly
1610
+ ? await applyAppearance()
1611
+ : await save({ preventDefault() {} });
1353
1612
  if (result) performNavigation(destination, ui.pending.root);
1354
1613
  else dispatchUI({ type: 'stay' });
1355
1614
  }
1356
1615
  function updateAppearance(patch) {
1616
+ if (patch.petModel) features.useBuiltin();
1357
1617
  setAppearanceDraft((current) => ({ ...current, ...patch }));
1358
1618
  }
1359
1619
  function openAppearance(event) {
@@ -1363,7 +1623,7 @@ window.__ModuleLoader__.load({
1363
1623
  navigateScreen('schedule', event);
1364
1624
  }
1365
1625
  function closeEditor() {
1366
- if ((!data?.configured && !appearanceOnly) || busy) return;
1626
+ if (busy) return;
1367
1627
  performNavigation(ui.back);
1368
1628
  }
1369
1629
  function update(key, value) {
@@ -1432,20 +1692,19 @@ window.__ModuleLoader__.load({
1432
1692
  'button',
1433
1693
  {
1434
1694
  type: 'button',
1435
- className: 'owc-text',
1695
+ className: 'owc-icon',
1436
1696
  'aria-pressed': view.masked,
1697
+ 'aria-label': t(view.masked ? 'show' : 'hide'),
1437
1698
  title: t(view.masked ? 'show' : 'hide'),
1438
1699
  onClick: () => setPreference({ masked: !view.masked }),
1439
1700
  },
1440
1701
  iconSvg(view.masked ? 'eyeOff' : 'eye', 13),
1441
- t(view.masked ? 'show' : 'hide'),
1442
1702
  );
1443
1703
 
1444
- const waterContext =
1445
- data?.configured && safeState && !error ? hydrationContext(settings, safeState) : null;
1446
- const waterState = hydrationView(water, waterContext, now);
1447
- const waterClaim = water.shift?.claim;
1704
+ const waterState = hydrationView(water, now);
1705
+ const waterClaim = water.claim;
1448
1706
  const waterPrompt =
1707
+ !features.ready &&
1449
1708
  waterReady &&
1450
1709
  !waterError &&
1451
1710
  !editing &&
@@ -1454,9 +1713,59 @@ window.__ModuleLoader__.load({
1454
1713
  !document.hidden &&
1455
1714
  waterState.phase === 'due' &&
1456
1715
  waterClaim?.owner === waterOwner.current &&
1457
- waterClaim.sequence === water.shift.sequence &&
1716
+ waterClaim.sequence === water.sequence &&
1458
1717
  waterClaim.until > Date.now() &&
1459
1718
  waterClaim.until <= Date.now() + HYDRATION_LEASE_MS;
1719
+ const reminderReady = !!(petMode && waterReady && !waterError);
1720
+ const reminderVisible = waterVisible && !document.hidden;
1721
+ const reminderAvailable = !petOpen && !ui.menu && !ui.pending && !busy;
1722
+ React.useEffect(() => {
1723
+ setReminderGate((current) =>
1724
+ nextPetReminder(current, {
1725
+ ready: reminderReady,
1726
+ eventId: waterState.eventId,
1727
+ phase: waterState.phase,
1728
+ visible: reminderVisible,
1729
+ available: reminderAvailable,
1730
+ ownsPrompt: waterPrompt,
1731
+ }),
1732
+ );
1733
+ }, [
1734
+ reminderReady,
1735
+ waterState.eventId,
1736
+ waterState.phase,
1737
+ reminderVisible,
1738
+ reminderAvailable,
1739
+ waterPrompt,
1740
+ ]);
1741
+ const autoWaterPrompt =
1742
+ reminderReady &&
1743
+ reminderVisible &&
1744
+ reminderAvailable &&
1745
+ waterPrompt &&
1746
+ reminderGate.open &&
1747
+ reminderGate.eventId === waterState.eventId;
1748
+ function openWaterPopup(event) {
1749
+ if (!waterPrompt || waterBusy) return;
1750
+ if (!reminderAvailable) {
1751
+ navigateScreen('home', event);
1752
+ return;
1753
+ }
1754
+ const trigger = event.currentTarget;
1755
+ setReminderGate({ eventId: waterState.eventId, armed: false, open: true });
1756
+ requestAnimationFrame(() => {
1757
+ const active = document.activeElement;
1758
+ if (active !== trigger && active?.tagName !== 'BODY') return;
1759
+ if (petPanel.current?.dataset.petScreen === 'reminder')
1760
+ petPanel.current.querySelector('.owc-water-actions button')?.focus();
1761
+ });
1762
+ }
1763
+ function dismissWaterPopup() {
1764
+ const hadFocus = petPanel.current?.contains(document.activeElement);
1765
+ setReminderGate(dismissPetReminder);
1766
+ if (hadFocus)
1767
+ requestAnimationFrame(() => box.current?.querySelector('.owc-pet-handle')?.focus());
1768
+ }
1460
1769
  const reactionSurface = [
1461
1770
  collapsed,
1462
1771
  editing,
@@ -1478,8 +1787,8 @@ window.__ModuleLoader__.load({
1478
1787
  workStatus: safeState?.status,
1479
1788
  phase: waterState.phase,
1480
1789
  ownsPrompt: waterPrompt,
1481
- ready: waterReady && !!safeState,
1482
- failed: waterError || !!error,
1790
+ ready: waterReady,
1791
+ failed: waterError,
1483
1792
  feedback: waterNotice || null,
1484
1793
  visible: view.companion,
1485
1794
  motion: view.companionMotion && !reactionState.current?.stopped,
@@ -1510,11 +1819,10 @@ window.__ModuleLoader__.load({
1510
1819
  waterVisible &&
1511
1820
  waterReady &&
1512
1821
  !waterError &&
1513
- !error &&
1514
1822
  !editing &&
1515
1823
  !waterEditing &&
1516
1824
  !ui.menu,
1517
- resting: ['quiet', 'rest', 'offline'].includes(companion.pose),
1825
+ resting: ['quiet', 'offline'].includes(companion.pose),
1518
1826
  });
1519
1827
  }, [
1520
1828
  petMode,
@@ -1535,6 +1843,60 @@ window.__ModuleLoader__.load({
1535
1843
  petReactionToken.current = token;
1536
1844
  if (petMode && companion.animate) petMotion.current?.play('celebrate');
1537
1845
  }, [petMode, waterNotice?.token, companion.animate]);
1846
+ React.useEffect(() => {
1847
+ const media = window.matchMedia?.('(prefers-reduced-motion: reduce)');
1848
+ const update = () => setPreviewReduced(!!media?.matches);
1849
+ media?.addEventListener?.('change', update);
1850
+ return () => media?.removeEventListener?.('change', update);
1851
+ }, []);
1852
+ React.useLayoutEffect(() => {
1853
+ if (!appearanceOnly || ui.pending) return;
1854
+ setPreviewState({ phase: 'idle', action: null });
1855
+ if (!view.companion || petModel(view.petModel).renderer !== 'video') return;
1856
+ const artwork = previewBox.current?.querySelector('.owc-pet-art');
1857
+ if (!artwork) return;
1858
+ const base = createPetMotion(artwork, window, { enabled: false, ambient: false });
1859
+ const controller = createPetVideoMotion(
1860
+ artwork,
1861
+ window,
1862
+ base,
1863
+ PET_CLIPS.models[view.petModel],
1864
+ PET_CLIPS.probe,
1865
+ {
1866
+ autoIdle: false,
1867
+ fallback: false,
1868
+ interruptible: true,
1869
+ onStateChange: setPreviewState,
1870
+ },
1871
+ );
1872
+ previewMotion.current = controller;
1873
+ return () => {
1874
+ controller.destroy();
1875
+ previewMotion.current = null;
1876
+ };
1877
+ }, [appearanceOnly, view.companion, view.petModel, !!ui.pending]);
1878
+ React.useLayoutEffect(() => {
1879
+ previewMotion.current?.configure({
1880
+ enabled:
1881
+ appearanceOnly &&
1882
+ view.companion &&
1883
+ view.companionMotion &&
1884
+ waterVisible &&
1885
+ !previewReduced &&
1886
+ !ui.menu &&
1887
+ !ui.pending,
1888
+ resting: false,
1889
+ });
1890
+ }, [
1891
+ appearanceOnly,
1892
+ view.companion,
1893
+ view.petModel,
1894
+ view.companionMotion,
1895
+ waterVisible,
1896
+ previewReduced,
1897
+ !!ui.menu,
1898
+ !!ui.pending,
1899
+ ]);
1538
1900
  const companionCard = (compact = false) =>
1539
1901
  companion.visible &&
1540
1902
  h(
@@ -1565,20 +1927,13 @@ window.__ModuleLoader__.load({
1565
1927
  const waterSummary = () => {
1566
1928
  if (!waterReady) return t('waterLoading');
1567
1929
  if (waterError) return t('waterUnavailable');
1568
- if (
1569
- water.enabled &&
1570
- waterContext &&
1571
- water.shift &&
1572
- water.shift.config !== waterContext.config
1573
- )
1574
- return t('waterStale');
1575
1930
  const labels = {
1576
1931
  off: 'waterOff',
1577
1932
  waiting: 'waterWaiting',
1578
1933
  break: 'waterBreak',
1579
1934
  paused: 'waterPaused',
1580
1935
  finished: 'waterFinished',
1581
- due: waterPrompt ? 'waterDue' : 'waterOther',
1936
+ due: 'waterDue',
1582
1937
  };
1583
1938
  if (waterState.phase === 'quiet')
1584
1939
  return t('waterQuiet') + ' · ' + formatDuration(waterState.quietRemaining / 1000);
@@ -1586,19 +1941,274 @@ window.__ModuleLoader__.load({
1586
1941
  ? t('waterNext') + ' · ' + formatDuration(waterState.remaining / 1000)
1587
1942
  : t(labels[waterState.phase]);
1588
1943
  };
1589
- const waterCanRecord =
1590
- waterReady &&
1591
- !waterError &&
1592
- data?.configured &&
1593
- ['working', 'lunch'].includes(waterContext?.status) &&
1594
- (!water.shift || water.shift.config === waterContext.config);
1595
- const waterCanQuiet = waterCanRecord && water.enabled && !water.shift?.paused;
1944
+ const waterCountdownText = () => {
1945
+ if (waterError) return t('timerUnavailable');
1946
+ if (!waterReady) return t('timerLoading');
1947
+ if (waterState.phase === 'due' && features.waterSnoozedUntil > now)
1948
+ return formatDuration(Math.ceil((features.waterSnoozedUntil - now) / 1000));
1949
+ if (['countdown', 'due', 'break'].includes(waterState.phase))
1950
+ return formatDuration(Math.ceil(waterState.remaining / 1000));
1951
+ if (waterState.phase === 'quiet')
1952
+ return formatDuration(Math.ceil(waterState.quietRemaining / 1000));
1953
+ return t(
1954
+ {
1955
+ off: 'timerOff',
1956
+ waiting: 'timerWaiting',
1957
+ paused: 'timerPaused',
1958
+ finished: 'timerFinished',
1959
+ }[waterState.phase] || 'timerUnavailable',
1960
+ );
1961
+ };
1962
+ const smokingCountdownText = () =>
1963
+ smokingUnavailable
1964
+ ? t(smokingError ? 'timerUnavailable' : 'timerLoading')
1965
+ : smokingState.phase === 'idle'
1966
+ ? t('timerIdle')
1967
+ : smokingState.phase === 'due' && features.smokingSnoozedUntil > now
1968
+ ? formatDuration(Math.ceil((features.smokingSnoozedUntil - now) / 1000))
1969
+ : formatDuration(Math.ceil(smokingState.remaining / 1000));
1970
+ const timerRow = (kind, value, due, title) => {
1971
+ let phase = kind === 'water' ? waterState.phase : smokingState.phase;
1972
+ if (
1973
+ phase === 'due' &&
1974
+ (kind === 'water' ? features.waterSnoozedUntil : features.smokingSnoozedUntil) > now
1975
+ )
1976
+ phase = 'snoozed';
1977
+ const extra = {
1978
+ snoozed: 'timerSnoozed',
1979
+ paused: 'timerPaused',
1980
+ break: 'timerBreak',
1981
+ quiet: 'timerQuiet',
1982
+ due: 'timerDue',
1983
+ }[phase];
1984
+ return h(
1985
+ 'button',
1986
+ {
1987
+ type: 'button',
1988
+ onClick: (event) => navigateScreen(kind === 'water' ? 'home' : 'smoking', event),
1989
+ className: 'owc-pet-timer-row',
1990
+ 'data-countdown': kind,
1991
+ 'data-countdown-due': due && phase !== 'snoozed',
1992
+ 'data-countdown-phase': phase,
1993
+ 'aria-label': t(kind === 'water' ? 'waterCountdown' : 'smokingCountdown'),
1994
+ title: phase === 'snoozed' ? t('timerSnoozed') + ' · ' + value : title,
1995
+ },
1996
+ h(
1997
+ 'span',
1998
+ { className: 'owc-pet-timer-label' },
1999
+ iconSvg(kind, 14),
2000
+ extra && h('span', { className: 'owc-sr-only' }, t(extra)),
2001
+ ),
2002
+ h(
2003
+ 'strong',
2004
+ null,
2005
+ ['paused', 'break'].includes(phase) &&
2006
+ /^\d/.test(value) &&
2007
+ h('span', { className: 'owc-timer-pause', 'aria-hidden': true }, 'Ⅱ'),
2008
+ phase === 'quiet' &&
2009
+ h('span', { className: 'owc-timer-pause', 'aria-hidden': true }, '☾'),
2010
+ value.startsWith('00:') ? value.slice(3) : value.replace(/^0(?=\d:)/, ''),
2011
+ ),
2012
+ );
2013
+ };
2014
+ const waterHero = () => {
2015
+ const ticking =
2016
+ waterReady && !waterError && ['countdown', 'due', 'quiet'].includes(waterState.phase);
2017
+ return h(
2018
+ 'div',
2019
+ { className: 'owc-detail-hero' },
2020
+ h(
2021
+ 'p',
2022
+ { className: 'owc-detail-kicker', 'data-water-status': true },
2023
+ t(
2024
+ ticking
2025
+ ? features.waterSnoozedUntil > now
2026
+ ? 'timerSnoozed'
2027
+ : waterState.phase === 'quiet'
2028
+ ? 'waterQuiet'
2029
+ : waterState.phase === 'due'
2030
+ ? 'waterDue'
2031
+ : 'waterNext'
2032
+ : 'waterTitle',
2033
+ ),
2034
+ ),
2035
+ h(
2036
+ 'div',
2037
+ {
2038
+ className: 'owc-detail-value' + (ticking ? '' : ' owc-detail-state'),
2039
+ role: 'timer',
2040
+ 'aria-label': t('waterCountdown'),
2041
+ },
2042
+ waterCountdownText(),
2043
+ ),
2044
+ h(
2045
+ 'div',
2046
+ { className: 'owc-detail-meta' },
2047
+ h(
2048
+ 'span',
2049
+ { 'data-water-count': true },
2050
+ t('waterCount') + ' · ' + (waterReady && !waterError ? waterState.count : '—'),
2051
+ ),
2052
+ h(
2053
+ 'span',
2054
+ { className: 'owc-detail-feedback' },
2055
+ waterNotice ? t(waterNotice.key) : '',
2056
+ ),
2057
+ ),
2058
+ );
2059
+ };
2060
+ const waterCountdownCard = () =>
2061
+ h(
2062
+ 'div',
2063
+ { className: 'owc-countdown-card', 'data-water-countdown-card': true },
2064
+ waterHero(),
2065
+ h(
2066
+ 'div',
2067
+ { className: 'owc-countdown-actions' },
2068
+ h(
2069
+ 'button',
2070
+ {
2071
+ type: 'button',
2072
+ className: 'owc-primary owc-water-record',
2073
+ disabled: !waterCanRecord || waterBusy,
2074
+ onClick: recordWater,
2075
+ },
2076
+ t('petRecord'),
2077
+ ),
2078
+ h(
2079
+ 'button',
2080
+ {
2081
+ type: 'button',
2082
+ className: 'owc-text',
2083
+ onClick: (event) => navigateScreen('home', event),
2084
+ },
2085
+ t('timerWaterDetails'),
2086
+ ),
2087
+ ),
2088
+ );
2089
+ const smokingCountdownCard = () =>
2090
+ h(
2091
+ 'div',
2092
+ { className: 'owc-countdown-card', 'data-smoking-countdown-card': true },
2093
+ h(
2094
+ 'div',
2095
+ {
2096
+ className: 'owc-countdown-value',
2097
+ role: 'timer',
2098
+ 'aria-label': t('smokingCountdown'),
2099
+ },
2100
+ !smokingUnavailable && smokingState.phase === 'idle'
2101
+ ? formatDuration(smoking.minutes * 60)
2102
+ : smokingCountdownText(),
2103
+ ),
2104
+ h(
2105
+ 'p',
2106
+ { role: 'status' },
2107
+ smokingUnavailable
2108
+ ? t(smokingError ? 'timerUnavailable' : 'timerLoading')
2109
+ : smokingState.phase === 'due' && features.smokingSnoozedUntil > now
2110
+ ? t('timerSnoozed')
2111
+ : t(
2112
+ {
2113
+ idle: 'timerIdle',
2114
+ running: 'timerRunning',
2115
+ paused: 'timerPaused',
2116
+ due: 'timerDue',
2117
+ }[smokingState.phase],
2118
+ ),
2119
+ ),
2120
+ h(
2121
+ 'label',
2122
+ null,
2123
+ t('timerInterval'),
2124
+ h(
2125
+ 'select',
2126
+ {
2127
+ value: smoking.minutes,
2128
+ disabled: smokingUnavailable || smokingBusy,
2129
+ onChange: (event) =>
2130
+ smokingAction({ type: 'interval', minutes: Number(event.target.value) }),
2131
+ },
2132
+ COUNTDOWN_INTERVALS.map((minutes) =>
2133
+ h('option', { key: minutes, value: minutes }, String(minutes)),
2134
+ ),
2135
+ ),
2136
+ ),
2137
+ h(
2138
+ 'div',
2139
+ { className: 'owc-countdown-actions' },
2140
+ h(
2141
+ 'button',
2142
+ {
2143
+ type: 'button',
2144
+ className: 'owc-primary',
2145
+ disabled: smokingUnavailable || smokingBusy,
2146
+ onClick: () =>
2147
+ smokingAction({
2148
+ type:
2149
+ smokingState.phase === 'running'
2150
+ ? 'pause'
2151
+ : smokingState.phase === 'paused'
2152
+ ? 'resume'
2153
+ : 'start',
2154
+ }),
2155
+ },
2156
+ t(
2157
+ smokingState.phase === 'running'
2158
+ ? 'timerPause'
2159
+ : smokingState.phase === 'paused'
2160
+ ? 'timerResume'
2161
+ : 'timerStart',
2162
+ ),
2163
+ ),
2164
+ smokingState.phase !== 'idle' &&
2165
+ h(
2166
+ 'button',
2167
+ {
2168
+ type: 'button',
2169
+ disabled: smokingUnavailable || smokingBusy,
2170
+ onClick: () => smokingAction({ type: 'restart' }),
2171
+ },
2172
+ t('timerRestart'),
2173
+ ),
2174
+ smokingState.phase !== 'idle' &&
2175
+ h(
2176
+ 'button',
2177
+ {
2178
+ type: 'button',
2179
+ disabled: smokingUnavailable || smokingBusy,
2180
+ onClick: () => smokingAction({ type: 'stop' }),
2181
+ },
2182
+ t('timerStop'),
2183
+ ),
2184
+ ),
2185
+ smokingState.phase !== 'idle' &&
2186
+ h('p', { className: 'owc-hint' }, t('detailNextInterval')),
2187
+ h(
2188
+ 'details',
2189
+ { className: 'owc-detail-more' },
2190
+ h('summary', null, t('detailHelp')),
2191
+ h('p', null, t('timerHint')),
2192
+ ),
2193
+ smokingError && h('p', { className: 'owc-error', role: 'alert' }, t(smokingError)),
2194
+ smokingError === 'timerStorageError' &&
2195
+ h(
2196
+ 'button',
2197
+ {
2198
+ type: 'button',
2199
+ disabled: smokingBusy,
2200
+ onClick: () => smokingAction({ type: 'retry' }),
2201
+ },
2202
+ t('timerRetry'),
2203
+ ),
2204
+ );
2205
+ const waterCanRecord = waterReady && !waterError;
2206
+ const waterCanQuiet = waterCanRecord && water.enabled && !water.paused;
1596
2207
  const recordWater = (event) => {
1597
2208
  if (event.detail > 1) return;
1598
2209
  if (!manualAttempt.current)
1599
2210
  manualAttempt.current = {
1600
2211
  type: 'manual-drink',
1601
- shiftId: waterContext.id,
1602
2212
  actionId: window.crypto?.randomUUID?.() || String(Math.random()) + Date.now(),
1603
2213
  ...(waterState.phase === 'due' ? { eventId: waterState.eventId } : {}),
1604
2214
  };
@@ -1667,14 +2277,10 @@ window.__ModuleLoader__.load({
1667
2277
  'button',
1668
2278
  {
1669
2279
  type: 'button',
1670
- disabled:
1671
- waterError ||
1672
- waterBusy ||
1673
- !waterContext ||
1674
- water.shift?.id !== waterContext.id,
1675
- onClick: () => waterAction({ type: water.shift?.paused ? 'resume' : 'pause' }),
2280
+ disabled: waterError || waterBusy || !waterReady,
2281
+ onClick: () => waterAction({ type: water.paused ? 'resume' : 'pause' }),
1676
2282
  },
1677
- t(water.shift?.paused ? 'waterResume' : 'waterPause'),
2283
+ t(water.paused ? 'waterResume' : 'waterPause'),
1678
2284
  ),
1679
2285
  waterError &&
1680
2286
  h(
@@ -1724,7 +2330,7 @@ window.__ModuleLoader__.load({
1724
2330
  className: 'owc-text',
1725
2331
  style: { marginLeft: 'auto' },
1726
2332
  'aria-label': t('waterEdit'),
1727
- disabled: !waterReady || !data?.configured,
2333
+ disabled: !waterReady,
1728
2334
  onClick: openWaterEditor,
1729
2335
  },
1730
2336
  t('settings'),
@@ -1757,16 +2363,59 @@ window.__ModuleLoader__.load({
1757
2363
  { 'data-water-effective': water.interval },
1758
2364
  t('waterEvery') + ' ' + water.interval + ' ' + t('waterMinutes'),
1759
2365
  ),
1760
- h('span', { 'data-water-count': true }, t('waterCount') + ': ' + waterState.count),
2366
+ h(
2367
+ 'span',
2368
+ { 'data-water-count': true },
2369
+ t('waterCount') + ': ' + (waterReady && !waterError ? waterState.count : '—'),
2370
+ ),
1761
2371
  ),
1762
2372
  waterControls(),
1763
- !['working', 'lunch'].includes(waterContext?.status) &&
1764
- h('p', { className: 'owc-hint' }, t('waterRecordHint')),
2373
+ h('p', { className: 'owc-hint' }, t('waterRecordHint')),
1765
2374
  waterState.phase === 'quiet' &&
1766
2375
  h('p', { className: 'owc-hint' }, t('waterQuietHint')),
1767
2376
  waterNotice && !view.companion && h('p', null, t(waterNotice.key)),
1768
2377
  );
1769
2378
 
2379
+ const waterIntervalField = () =>
2380
+ h(
2381
+ 'label',
2382
+ { className: 'owc-water-interval' },
2383
+ h('span', null, t('waterInterval')),
2384
+ h('input', {
2385
+ name: 'waterInterval',
2386
+ type: 'number',
2387
+ inputMode: 'numeric',
2388
+ min: '5',
2389
+ max: '240',
2390
+ step: '1',
2391
+ value: waterInterval,
2392
+ 'aria-invalid': waterInvalid,
2393
+ onChange: (event) => {
2394
+ setWaterInterval(event.target.value);
2395
+ setWaterInvalid(false);
2396
+ },
2397
+ }),
2398
+ );
2399
+ const waterSettingsActions = () =>
2400
+ h(
2401
+ 'div',
2402
+ { className: 'owc-save' },
2403
+ h(
2404
+ 'button',
2405
+ { type: 'button', disabled: waterBusy, onClick: closeWaterEditor },
2406
+ t('cancel'),
2407
+ ),
2408
+ h(
2409
+ 'button',
2410
+ {
2411
+ type: 'submit',
2412
+ className: 'owc-primary',
2413
+ 'aria-label': t('waterSave'),
2414
+ disabled: !waterReady || waterError || waterBusy,
2415
+ },
2416
+ t(waterBusy ? 'busy' : 'save'),
2417
+ ),
2418
+ );
1770
2419
  const waterSettings = () =>
1771
2420
  h(
1772
2421
  'form',
@@ -1789,7 +2438,7 @@ window.__ModuleLoader__.load({
1789
2438
  h('input', {
1790
2439
  type: 'checkbox',
1791
2440
  checked: waterEnabledDraft,
1792
- disabled: !waterReady || waterError || waterBusy || !data?.configured,
2441
+ disabled: !waterReady || waterError || waterBusy,
1793
2442
  onChange: (event) => {
1794
2443
  setWaterEnabledDraft(event.target.checked);
1795
2444
  setWaterNotice(false);
@@ -1798,8 +2447,9 @@ window.__ModuleLoader__.load({
1798
2447
  ),
1799
2448
  h(
1800
2449
  'fieldset',
1801
- { disabled: !waterReady || waterError || waterBusy || !data?.configured },
1802
- h('legend', { className: 'owc-muted' }, t('waterPresets')),
2450
+ { disabled: !waterReady || waterError || waterBusy },
2451
+ h('legend', { className: 'owc-sr-only' }, t('waterPresets')),
2452
+ waterIntervalField(),
1803
2453
  h(
1804
2454
  'div',
1805
2455
  { className: 'owc-water-presets' },
@@ -1810,63 +2460,33 @@ window.__ModuleLoader__.load({
1810
2460
  type: 'button',
1811
2461
  key: minutes,
1812
2462
  'aria-pressed': Number(waterInterval) === minutes,
2463
+ 'aria-label': minutes + ' ' + t('waterMinutes'),
1813
2464
  onClick: () => {
1814
2465
  setWaterInterval(String(minutes));
1815
2466
  setWaterInvalid(false);
1816
2467
  },
1817
2468
  },
1818
- minutes + ' ' + t('waterMinutes'),
2469
+ String(minutes),
1819
2470
  ),
1820
2471
  ),
1821
2472
  ),
1822
- h(
1823
- 'label',
1824
- { className: 'owc-field' },
1825
- h('span', null, t('waterInterval')),
1826
- h('input', {
1827
- name: 'waterInterval',
1828
- type: 'number',
1829
- inputMode: 'numeric',
1830
- min: '5',
1831
- max: '240',
1832
- step: '1',
1833
- value: waterInterval,
1834
- 'aria-invalid': waterInvalid,
1835
- onChange: (event) => {
1836
- setWaterInterval(event.target.value);
1837
- setWaterInvalid(false);
1838
- },
1839
- }),
1840
- ),
1841
2473
  ),
1842
2474
  waterInvalid && h('p', { role: 'alert' }, t('waterInvalid')),
1843
- h(
1844
- 'p',
1845
- { 'data-water-saved-interval': water.interval },
1846
- t('waterEffective') + ': ' + water.interval + ' ' + t('waterMinutes'),
1847
- ),
1848
- h('p', { className: 'owc-hint' }, t('waterSaveHint')),
1849
- h('p', { className: 'owc-hint' }, t('waterBrief')),
1850
- waterError && h('p', { role: 'alert' }, t('waterUnavailable')),
1851
- !data?.configured && h('p', null, t('waterSetup')),
1852
- h(
1853
- 'div',
1854
- { className: 'owc-save' },
2475
+ (Number(waterInterval) !== water.interval || waterEnabledDraft !== water.enabled) &&
1855
2476
  h(
1856
- 'button',
1857
- { type: 'button', disabled: waterBusy, onClick: closeWaterEditor },
1858
- t('cancel'),
1859
- ),
1860
- h(
1861
- 'button',
1862
- {
1863
- type: 'submit',
1864
- className: 'owc-primary',
1865
- disabled: !waterReady || waterError || waterBusy || !data?.configured,
1866
- },
1867
- t(waterBusy ? 'busy' : 'waterSave'),
2477
+ 'p',
2478
+ { className: 'owc-hint', 'data-water-saved-interval': water.interval },
2479
+ t('detailSavedInterval') +
2480
+ ': ' +
2481
+ water.interval +
2482
+ ' ' +
2483
+ t('waterMinutes') +
2484
+ ' · ' +
2485
+ t('detailDraftHint'),
1868
2486
  ),
1869
- ),
2487
+ h('p', { className: 'owc-hint' }, t('detailWaterRule')),
2488
+ waterError && h('p', { role: 'alert' }, t('waterUnavailable')),
2489
+
1870
2490
  waterError &&
1871
2491
  h(
1872
2492
  'button',
@@ -1929,6 +2549,7 @@ window.__ModuleLoader__.load({
1929
2549
  ),
1930
2550
  ),
1931
2551
  ),
2552
+ waterSettingsActions(),
1932
2553
  );
1933
2554
  const waterReminder = () =>
1934
2555
  waterPrompt &&
@@ -1943,6 +2564,7 @@ window.__ModuleLoader__.load({
1943
2564
  'button',
1944
2565
  {
1945
2566
  type: 'button',
2567
+ className: 'owc-primary',
1946
2568
  disabled: waterBusy,
1947
2569
  onClick: () => waterAction({ type: 'drink', eventId: waterState.eventId }),
1948
2570
  },
@@ -1960,6 +2582,453 @@ window.__ModuleLoader__.load({
1960
2582
  ),
1961
2583
  );
1962
2584
 
2585
+ const classicMini = () => {
2586
+ const countdown = classicClock.seconds !== null;
2587
+ const caption =
2588
+ {
2589
+ working: 'classicMiniWorking',
2590
+ before: 'classicMiniBefore',
2591
+ lunch: 'classicMiniLunch',
2592
+ }[classicClock.phase] || 'classicMiniStatus';
2593
+ const stateTitle =
2594
+ { setup: 'petNeedsSetup', error: 'petLoadFailed', waiting: 'petNeedsSession' }[
2595
+ classicClock.phase
2596
+ ] || classicClock.title;
2597
+ const valid = !!data?.configured && !!safeState && !error;
2598
+ const showIncome = valid && ['working', 'lunch', 'done'].includes(safeState.status);
2599
+ const compact = (value) => {
2600
+ if (value === t('timerUnavailable')) return '—';
2601
+ if (value === t('timerWaiting')) return t('classicMiniWaiting');
2602
+ if (value === t('timerFinished')) return t('classicMiniFinished');
2603
+ return value.startsWith('00:') ? value.slice(3) : value.replace(/^0(?=\d:)/, '');
2604
+ };
2605
+ const tile = (kind, screen, label, value, summary, due) =>
2606
+ h(
2607
+ 'button',
2608
+ {
2609
+ type: 'button',
2610
+ className: 'owc-classic-mini-timer',
2611
+ 'data-classic-timer': kind,
2612
+ 'data-countdown-due': due,
2613
+ 'aria-label': t(label),
2614
+ title: t(label) + ' · ' + summary,
2615
+ disabled: busy || waterBusy,
2616
+ onClick: (event) => navigateScreen(screen, event),
2617
+ },
2618
+ iconSvg(kind, 13),
2619
+ h('strong', null, compact(value)),
2620
+ );
2621
+ return h(
2622
+ 'div',
2623
+ {
2624
+ className: 'owc-classic-mini',
2625
+ 'data-classic-overview': true,
2626
+ 'data-clock-phase': classicClock.phase,
2627
+ },
2628
+ h(
2629
+ 'div',
2630
+ { className: 'owc-classic-mini-top' },
2631
+ h(
2632
+ 'div',
2633
+ {
2634
+ className: 'owc-grip',
2635
+ tabIndex: 0,
2636
+ title: t(classicClock.hint) + ' · ' + t('drag'),
2637
+ onKeyDown: gripKeys,
2638
+ },
2639
+ h('span', { className: 'owc-classic-mini-label' }, t(caption)),
2640
+ countdown
2641
+ ? h(
2642
+ 'button',
2643
+ {
2644
+ type: 'button',
2645
+ onClick: (event) => navigateScreen('income', event),
2646
+ className: 'owc-classic-mini-time',
2647
+ 'aria-label': t('petCountdownCard'),
2648
+ },
2649
+ formatDuration(classicClock.seconds),
2650
+ )
2651
+ : ['setup', 'error'].includes(classicClock.phase)
2652
+ ? h(
2653
+ 'button',
2654
+ {
2655
+ type: 'button',
2656
+ className: 'owc-classic-mini-state',
2657
+ disabled: hostBusy,
2658
+ 'aria-label': t(
2659
+ classicClock.phase === 'setup' ? 'classicConfigure' : 'retry',
2660
+ ),
2661
+ onClick:
2662
+ classicClock.phase === 'setup' ? openEditor : () => reload(true),
2663
+ },
2664
+ t(stateTitle),
2665
+ )
2666
+ : h(
2667
+ 'strong',
2668
+ { className: 'owc-classic-mini-state', role: 'status' },
2669
+ t(stateTitle),
2670
+ ),
2671
+ ),
2672
+ h(
2673
+ 'button',
2674
+ {
2675
+ type: 'button',
2676
+ className: 'owc-icon',
2677
+ title: t('expand'),
2678
+ 'aria-label': t('expand'),
2679
+ onClick: () => setPreference({ mini: false }),
2680
+ },
2681
+ iconSvg('chevronDown', 14),
2682
+ ),
2683
+ ),
2684
+ h(
2685
+ 'div',
2686
+ { className: 'owc-classic-mini-timers' },
2687
+ tile(
2688
+ 'water',
2689
+ 'water-countdown',
2690
+ 'waterCountdown',
2691
+ waterCountdownText(),
2692
+ waterSummary(),
2693
+ waterState.phase === 'due',
2694
+ ),
2695
+ tile(
2696
+ 'smoking',
2697
+ 'smoking',
2698
+ 'smokingCountdown',
2699
+ smokingCountdownText(),
2700
+ t(
2701
+ smokingState.phase === 'paused'
2702
+ ? 'timerPaused'
2703
+ : smokingState.phase === 'due'
2704
+ ? 'timerDue'
2705
+ : smokingState.phase === 'idle'
2706
+ ? 'timerIdle'
2707
+ : 'timerRunning',
2708
+ ),
2709
+ smokingState.phase === 'due',
2710
+ ),
2711
+ h(
2712
+ 'div',
2713
+ { className: 'owc-classic-mini-income' },
2714
+ showIncome &&
2715
+ !view.masked &&
2716
+ h(
2717
+ 'button',
2718
+ {
2719
+ type: 'button',
2720
+ className: 'owc-classic-mini-amount',
2721
+ 'aria-label': t('classicIncomeEntry'),
2722
+ 'aria-description': moneyText,
2723
+ title: moneyText,
2724
+ onClick: (event) => navigateScreen('earnings', event),
2725
+ },
2726
+ moneyText,
2727
+ ),
2728
+ h(
2729
+ 'button',
2730
+ {
2731
+ type: 'button',
2732
+ className: 'owc-icon',
2733
+ disabled: !valid,
2734
+ 'aria-label': t(
2735
+ showIncome ? (view.masked ? 'show' : 'hide') : 'classicIncomeEntry',
2736
+ ),
2737
+ title: t(showIncome ? (view.masked ? 'show' : 'hide') : 'classicIncomeEntry'),
2738
+ 'aria-pressed': showIncome ? !view.masked : undefined,
2739
+ onClick: (event) =>
2740
+ showIncome
2741
+ ? setPreference({ masked: !view.masked })
2742
+ : navigateScreen('earnings', event),
2743
+ },
2744
+ iconSvg(view.masked || !showIncome ? 'eyeOff' : 'eye', 13),
2745
+ ),
2746
+ ),
2747
+ ),
2748
+ );
2749
+ };
2750
+
2751
+ const classicHeader = () =>
2752
+ h(
2753
+ 'div',
2754
+ { className: 'owc-head owc-classic-head' },
2755
+ ui.screen &&
2756
+ h(
2757
+ 'button',
2758
+ {
2759
+ type: 'button',
2760
+ className: 'owc-icon',
2761
+ 'aria-label': t(
2762
+ PET_EDITOR_SCREENS.includes(ui.screen) && ui.back
2763
+ ? 'petBack'
2764
+ : 'classicReturn',
2765
+ ),
2766
+ title: t(
2767
+ PET_EDITOR_SCREENS.includes(ui.screen) && ui.back
2768
+ ? 'petBack'
2769
+ : 'classicReturn',
2770
+ ),
2771
+ disabled: busy || waterBusy || !!ui.pending,
2772
+ onClick: () =>
2773
+ navigateScreen(PET_EDITOR_SCREENS.includes(ui.screen) ? ui.back : null),
2774
+ },
2775
+ '←',
2776
+ ),
2777
+ h(
2778
+ 'strong',
2779
+ { className: 'owc-title', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys },
2780
+ t(ui.screen ? pageTitle(ui.screen) : 'classicOverview'),
2781
+ ),
2782
+ ['home', 'water-countdown'].includes(ui.screen) &&
2783
+ h(
2784
+ 'button',
2785
+ {
2786
+ type: 'button',
2787
+ className: 'owc-icon',
2788
+ 'aria-label': t('petWaterSettings'),
2789
+ title: t('petWaterSettings'),
2790
+ disabled: busy || waterBusy || !!ui.pending,
2791
+ onClick: openWaterEditor,
2792
+ },
2793
+ iconSvg('sliders', 14),
2794
+ ),
2795
+ waterPrompt &&
2796
+ ui.screen &&
2797
+ !['home', 'water-countdown'].includes(ui.screen) &&
2798
+ h(
2799
+ 'button',
2800
+ {
2801
+ type: 'button',
2802
+ className: 'owc-icon',
2803
+ 'aria-label': t('waterDue'),
2804
+ title: t('waterDue'),
2805
+ onClick: (event) => navigateScreen('home', event),
2806
+ },
2807
+ iconSvg('water'),
2808
+ ),
2809
+ ui.screen &&
2810
+ h(
2811
+ 'button',
2812
+ {
2813
+ type: 'button',
2814
+ className: 'owc-icon',
2815
+ 'data-panel-more': true,
2816
+ 'aria-label': t('quickMore'),
2817
+ onClick: openQuickMenu,
2818
+ },
2819
+ '⋯',
2820
+ ),
2821
+ !ui.screen &&
2822
+ h(
2823
+ React.Fragment,
2824
+ null,
2825
+ h(
2826
+ 'button',
2827
+ { type: 'button', className: 'owc-text', onClick: openAppearance },
2828
+ t('appearanceGroup'),
2829
+ ),
2830
+ h(
2831
+ 'button',
2832
+ {
2833
+ type: 'button',
2834
+ className: 'owc-icon',
2835
+ 'aria-label': t('petWorkSettings'),
2836
+ title: t('petWorkSettings'),
2837
+ disabled: hostBusy || !data,
2838
+ onClick: openEditor,
2839
+ },
2840
+ iconSvg('sliders'),
2841
+ ),
2842
+ h(
2843
+ 'button',
2844
+ {
2845
+ type: 'button',
2846
+ className: 'owc-icon',
2847
+ 'aria-label': t(collapsed ? 'classicMore' : 'classicLess'),
2848
+ title: t(collapsed ? 'classicMore' : 'classicLess'),
2849
+ onClick: () => setPreference({ mini: !view.mini }),
2850
+ },
2851
+ iconSvg(collapsed ? 'chevronDown' : 'chevronUp'),
2852
+ ),
2853
+ ),
2854
+ );
2855
+ const classicOverview = () => {
2856
+ const countdown = classicClock.seconds !== null;
2857
+ const valid = !!data?.configured && !!safeState && !error;
2858
+ const showIncome = valid && ['working', 'lunch', 'done'].includes(safeState.status);
2859
+ const tile = (kind, screen, label, value, summary) =>
2860
+ h(
2861
+ 'button',
2862
+ {
2863
+ type: 'button',
2864
+ className: 'owc-classic-tile',
2865
+ 'data-classic-timer': kind,
2866
+ 'aria-label': t(label),
2867
+ disabled: busy || waterBusy,
2868
+ onClick: (event) => navigateScreen(screen, event),
2869
+ },
2870
+ h(
2871
+ 'span',
2872
+ { className: 'owc-classic-tile-title' },
2873
+ iconSvg(kind, 14),
2874
+ t(kind === 'water' ? 'waterTimerShort' : 'smokingTimerShort'),
2875
+ ),
2876
+ h('strong', null, value),
2877
+ h('span', { className: 'owc-classic-tile-hint' }, summary),
2878
+ );
2879
+ return h(
2880
+ 'div',
2881
+ {
2882
+ className: 'owc-classic-overview',
2883
+ 'data-classic-overview': true,
2884
+ 'data-clock-phase': classicClock.phase,
2885
+ },
2886
+ h(
2887
+ 'div',
2888
+ { className: 'owc-classic-hero' },
2889
+ h(
2890
+ 'div',
2891
+ { className: 'owc-classic-kicker' },
2892
+ iconSvg('alarm', 14),
2893
+ t(countdown ? classicClock.title : 'title'),
2894
+ ),
2895
+ countdown
2896
+ ? h(
2897
+ 'div',
2898
+ {
2899
+ className: 'owc-classic-time',
2900
+ role: 'timer',
2901
+ 'aria-label': t(classicClock.title),
2902
+ },
2903
+ formatDuration(classicClock.seconds),
2904
+ )
2905
+ : h(
2906
+ 'h3',
2907
+ { className: 'owc-classic-state', role: 'status' },
2908
+ t(classicClock.title),
2909
+ ),
2910
+ h('p', { className: 'owc-classic-hint' }, t(classicClock.hint)),
2911
+ classicClock.phase === 'setup' &&
2912
+ h(
2913
+ 'button',
2914
+ { type: 'button', className: 'owc-primary', onClick: openEditor },
2915
+ t('classicConfigure'),
2916
+ ),
2917
+ classicClock.phase === 'error' &&
2918
+ h(
2919
+ 'button',
2920
+ {
2921
+ type: 'button',
2922
+ disabled: hostBusy,
2923
+ onClick: () => reload(true),
2924
+ },
2925
+ iconSvg('refresh', 12),
2926
+ t('retry'),
2927
+ ),
2928
+ valid &&
2929
+ h(
2930
+ 'div',
2931
+ { className: 'owc-classic-schedule' },
2932
+ h(
2933
+ 'span',
2934
+ null,
2935
+ settings.start + ' — ' + settings.end + (safeState.overnight ? ' +1' : ''),
2936
+ ),
2937
+ h('span', null, t('classicEnd') + ' ' + settings.end),
2938
+ ),
2939
+ ),
2940
+ h(
2941
+ 'div',
2942
+ { className: 'owc-classic-timers' },
2943
+ tile(
2944
+ 'water',
2945
+ 'water-countdown',
2946
+ 'waterCountdown',
2947
+ waterCountdownText(),
2948
+ waterState.phase === 'countdown' ? t('classicWaterHint') : waterSummary(),
2949
+ ),
2950
+ tile(
2951
+ 'smoking',
2952
+ 'smoking',
2953
+ 'smokingCountdown',
2954
+ smokingCountdownText(),
2955
+ smokingUnavailable
2956
+ ? t(smokingError ? 'timerUnavailable' : 'timerLoading')
2957
+ : t(
2958
+ {
2959
+ idle: 'classicSmokeHint',
2960
+ running: 'classicSmokeHint',
2961
+ paused: 'timerPaused',
2962
+ due: 'timerDue',
2963
+ }[smokingState.phase],
2964
+ ),
2965
+ ),
2966
+ ),
2967
+ h(
2968
+ 'div',
2969
+ { className: 'owc-classic-income' },
2970
+ h(
2971
+ 'button',
2972
+ {
2973
+ type: 'button',
2974
+ className: 'owc-classic-income-link',
2975
+ disabled: !valid,
2976
+ onClick: (event) => navigateScreen('earnings', event),
2977
+ },
2978
+ h('span', null, t(showIncome ? 'classicIncome' : 'classicIncomeEntry')),
2979
+ showIncome &&
2980
+ h('strong', { 'aria-label': view.masked ? t('masked') : moneyText }, moneyText),
2981
+ h('span', { 'aria-hidden': true }, '›'),
2982
+ ),
2983
+ showIncome && salaryToggle(),
2984
+ ),
2985
+ !collapsed &&
2986
+ h(
2987
+ 'div',
2988
+ { className: 'owc-classic-details' },
2989
+ valid
2990
+ ? h(
2991
+ React.Fragment,
2992
+ null,
2993
+ h(
2994
+ 'div',
2995
+ { className: 'owc-classic-detail-title' },
2996
+ t('classicShift'),
2997
+ h('span', null, Math.floor(safeState.progress * 100) + '%'),
2998
+ ),
2999
+ h(
3000
+ 'div',
3001
+ {
3002
+ className: 'owc-progress',
3003
+ role: 'progressbar',
3004
+ 'aria-label': t('progress'),
3005
+ 'aria-valuenow': Math.floor(safeState.progress * 100),
3006
+ 'aria-valuemin': 0,
3007
+ 'aria-valuemax': 100,
3008
+ },
3009
+ h('div', { style: { width: safeState.progress * 100 + '%' } }),
3010
+ ),
3011
+ h(
3012
+ 'p',
3013
+ null,
3014
+ t('shiftDate') + ' ' + safeState.shiftDate + ' · ' + settings.timezone,
3015
+ ),
3016
+ h(
3017
+ 'p',
3018
+ null,
3019
+ settings.breakStart === settings.breakEnd
3020
+ ? t('noBreak')
3021
+ : t('lunch') + ' ' + settings.breakStart + '–' + settings.breakEnd,
3022
+ ),
3023
+ h('button', { type: 'button', onClick: openEditor }, t('petWorkSettings')),
3024
+ )
3025
+ : h('p', null, t(classicClock.hint)),
3026
+ ),
3027
+ waterNotice &&
3028
+ h('p', { className: 'owc-classic-feedback', role: 'status' }, t(waterNotice.key)),
3029
+ );
3030
+ };
3031
+
1963
3032
  const strip = () =>
1964
3033
  h(
1965
3034
  'div',
@@ -2186,7 +3255,6 @@ window.__ModuleLoader__.load({
2186
3255
  h('span', { className: 'owc-dot' }),
2187
3256
  t('loading'),
2188
3257
  ),
2189
- !sessionId && h('p', { className: 'owc-hint' }, t('noSession')),
2190
3258
  h(
2191
3259
  'div',
2192
3260
  { className: 'owc-row-actions' },
@@ -2196,14 +3264,26 @@ window.__ModuleLoader__.load({
2196
3264
  type: 'button',
2197
3265
  className: 'owc-text',
2198
3266
  onClick: () => reload(true),
2199
- disabled: busy || !sessionId,
3267
+ disabled: hostBusy,
2200
3268
  },
2201
3269
  iconSvg('refresh', 12),
2202
3270
  t('retry'),
2203
3271
  ),
2204
3272
  ),
2205
3273
  );
2206
- const status = safeState.status === 'done' ? '' : t(safeState.status);
3274
+ const clockSeconds = ['done', 'rest'].includes(safeState.status) ? null : seconds;
3275
+ const clockTitle =
3276
+ safeState.status === 'before'
3277
+ ? 'before'
3278
+ : ['working', 'lunch'].includes(safeState.status)
3279
+ ? 'working'
3280
+ : safeState.status;
3281
+ const status =
3282
+ clockSeconds === null
3283
+ ? ''
3284
+ : safeState.status === 'lunch'
3285
+ ? t('lunch') + ' · ' + t('working')
3286
+ : t(clockTitle);
2207
3287
  return h(
2208
3288
  'div',
2209
3289
  { className: 'owc-main' },
@@ -2213,10 +3293,15 @@ window.__ModuleLoader__.load({
2213
3293
  section !== 'earnings' &&
2214
3294
  h(
2215
3295
  'div',
2216
- { className: 'owc-time', 'aria-label': status + ' ' + duration },
2217
- duration,
3296
+ {
3297
+ className: 'owc-time' + (clockSeconds === null ? ' owc-detail-state' : ''),
3298
+ role: clockSeconds === null ? 'status' : 'timer',
3299
+ 'aria-label': t(clockTitle),
3300
+ },
3301
+ clockSeconds === null ? t(clockTitle) : formatDuration(clockSeconds),
2218
3302
  ),
2219
3303
  section !== 'earnings' &&
3304
+ ['working', 'lunch', 'done'].includes(safeState.status) &&
2220
3305
  h(
2221
3306
  'div',
2222
3307
  {
@@ -2260,57 +3345,30 @@ window.__ModuleLoader__.load({
2260
3345
  ),
2261
3346
  ),
2262
3347
  h(
2263
- 'div',
2264
- { className: 'owc-meta' },
2265
- h(
2266
- 'span',
2267
- null,
2268
- settings.start +
2269
- ' — ' +
2270
- settings.end +
2271
- (settings.end < settings.start ? ' +1' : '') +
2272
- ' · ' +
2273
- (settings.breakStart === settings.breakEnd
2274
- ? t('noBreak')
2275
- : settings.breakStart + '–' + settings.breakEnd),
2276
- ),
2277
- h(
2278
- 'span',
2279
- null,
2280
- t('shiftDate') + ' ' + safeState.shiftDate + ' · ' + settings.timezone,
2281
- ),
2282
- ),
2283
- h(
2284
- 'div',
2285
- { className: 'owc-row-actions' },
3348
+ 'details',
3349
+ { className: 'owc-detail-more' },
3350
+ h('summary', null, t('detailShift')),
2286
3351
  h(
2287
- 'button',
2288
- {
2289
- type: 'button',
2290
- className: 'owc-text',
2291
- onClick: () => reload(true),
2292
- disabled: busy,
2293
- },
2294
- iconSvg('refresh', 12),
2295
- t('refresh'),
2296
- ),
2297
- petMode &&
3352
+ 'div',
3353
+ { className: 'owc-meta' },
2298
3354
  h(
2299
- 'button',
2300
- { type: 'button', className: 'owc-text', onClick: openEditor },
2301
- t('petWorkSettings'),
3355
+ 'span',
3356
+ null,
3357
+ settings.start +
3358
+ ' — ' +
3359
+ settings.end +
3360
+ (settings.end < settings.start ? ' +1' : '') +
3361
+ ' · ' +
3362
+ (settings.breakStart === settings.breakEnd
3363
+ ? t('noBreak')
3364
+ : settings.breakStart + '–' + settings.breakEnd),
2302
3365
  ),
2303
- !petMode &&
2304
3366
  h(
2305
- 'button',
2306
- {
2307
- type: 'button',
2308
- className: 'owc-text',
2309
- onClick: () =>
2310
- setPreference(petMode ? { petPosition: null } : { position: null }),
2311
- },
2312
- t('reset'),
3367
+ 'span',
3368
+ null,
3369
+ t('shiftDate') + ' ' + safeState.shiftDate + ' · ' + settings.timezone,
2313
3370
  ),
3371
+ ),
2314
3372
  ),
2315
3373
  );
2316
3374
  };
@@ -2331,100 +3389,201 @@ window.__ModuleLoader__.load({
2331
3389
  }),
2332
3390
  t('petMode'),
2333
3391
  ),
2334
- h(
2335
- 'label',
2336
- { className: 'owc-check' },
2337
- h('input', {
2338
- type: 'checkbox',
2339
- checked: view.companionMotion,
2340
- disabled: !view.companion,
2341
- onChange: (event) => updateAppearance({ companionMotion: event.target.checked }),
2342
- }),
2343
- t('companionMotion'),
2344
- ),
2345
3392
  label(
2346
3393
  t('petCharacter'),
2347
3394
  h(
2348
3395
  'select',
2349
3396
  {
2350
3397
  'aria-label': t('petCharacter'),
2351
- value: view.petModel,
3398
+ value: features.previewImage ? 'custom-image' : view.petModel,
2352
3399
  disabled: !view.companion,
2353
- onChange: (event) => updateAppearance({ petModel: event.target.value }),
3400
+ onChange: (event) =>
3401
+ event.target.value === 'custom-image'
3402
+ ? features.selectCustom()
3403
+ : updateAppearance({ petModel: event.target.value }),
2354
3404
  },
3405
+ features.hasCustom &&
3406
+ h(
3407
+ 'option',
3408
+ { value: 'custom-image' },
3409
+ locale.active.startsWith('en') ? 'Custom image' : '自定义图片',
3410
+ ),
2355
3411
  ...Object.values(PET_MODELS).map((model) =>
2356
3412
  h('option', { key: model.id, value: model.id }, t(model.label)),
2357
3413
  ),
2358
3414
  ),
2359
3415
  ),
2360
- label(
2361
- t('petSize'),
2362
- h('input', {
2363
- type: 'range',
2364
- min: '80',
2365
- max: '180',
2366
- step: '4',
2367
- value: view.petSize,
2368
- 'aria-label': t('petSize'),
2369
- disabled: !view.companion,
2370
- onChange: (event) => updateAppearance({ petSize: event.target.valueAsNumber }),
2371
- }),
2372
- ),
2373
- h('p', { className: 'owc-hint' }, t('petAppearanceHint')),
2374
- label(
2375
- t('opacity'),
3416
+ h(
3417
+ 'details',
3418
+ { className: 'owc-appearance-display' },
2376
3419
  h(
2377
- React.Fragment,
3420
+ 'summary',
2378
3421
  null,
3422
+ locale.active.startsWith('en') ? 'Display and motion' : '显示与动作',
3423
+ ),
3424
+ h(
3425
+ 'label',
3426
+ { className: 'owc-check' },
2379
3427
  h('input', {
2380
- className: 'owc-range',
2381
- type: 'range',
2382
- min: '30',
2383
- max: '100',
2384
- step: '1',
2385
- value: String(Math.round(view.opacity * 100)),
2386
- disabled: busy,
2387
- 'aria-label': t('opacity'),
3428
+ type: 'checkbox',
3429
+ checked: view.companionMotion,
3430
+ disabled: !view.companion,
2388
3431
  onChange: (event) =>
2389
- updateAppearance({ opacity: event.target.valueAsNumber / 100 }),
3432
+ updateAppearance({ companionMotion: event.target.checked }),
2390
3433
  }),
3434
+ t('companionMotion'),
3435
+ ),
3436
+ label(
3437
+ t('petSize'),
3438
+ h('input', {
3439
+ type: 'range',
3440
+ min: '80',
3441
+ max: '180',
3442
+ step: '4',
3443
+ value: view.petSize,
3444
+ 'aria-label': t('petSize'),
3445
+ disabled: !view.companion,
3446
+ onChange: (event) => updateAppearance({ petSize: event.target.valueAsNumber }),
3447
+ }),
3448
+ ),
3449
+ label(
3450
+ t('opacity'),
2391
3451
  h(
2392
- 'span',
2393
- { className: 'owc-hint', style: { margin: 0 } },
2394
- Math.round(view.opacity * 100) + '% · ' + t('opacityHint'),
3452
+ React.Fragment,
3453
+ null,
3454
+ h('input', {
3455
+ className: 'owc-range',
3456
+ type: 'range',
3457
+ min: '30',
3458
+ max: '100',
3459
+ step: '1',
3460
+ value: String(Math.round(view.opacity * 100)),
3461
+ disabled: busy,
3462
+ 'aria-label': t('opacity'),
3463
+ onChange: (event) =>
3464
+ updateAppearance({ opacity: event.target.valueAsNumber / 100 }),
3465
+ }),
3466
+ h(
3467
+ 'span',
3468
+ { className: 'owc-range-value', title: t('opacityHint') },
3469
+ Math.round(view.opacity * 100) + '%',
3470
+ ),
3471
+ ),
3472
+ ),
3473
+ ),
3474
+ );
3475
+ const previewActions = () => {
3476
+ if (!view.companion || petModel(view.petModel).renderer !== 'video') return null;
3477
+ const labels = {
3478
+ idle: 'petPreviewIdle',
3479
+ greet: 'petPreviewGreet',
3480
+ celebrate: 'petPreviewDrink',
3481
+ };
3482
+ const names = { idle: 'petTryIdle', greet: 'petTryGreet', celebrate: 'petTryDrink' };
3483
+ const clips = PET_CLIPS.models[view.petModel] || {};
3484
+ const blocked =
3485
+ !view.companionMotion || previewReduced || previewState.phase === 'unsupported';
3486
+ const message = previewReduced
3487
+ ? t('petPreviewReduced')
3488
+ : !view.companionMotion
3489
+ ? t('petPreviewEnable')
3490
+ : previewState.phase === 'loading'
3491
+ ? t('petPreviewLoading')
3492
+ : previewState.phase === 'playing'
3493
+ ? t('petPreviewPlaying') + ' · ' + t(labels[previewState.action])
3494
+ : previewState.phase === 'unsupported'
3495
+ ? t('petPreviewUnsupported')
3496
+ : previewState.phase === 'unavailable'
3497
+ ? t('petPreviewUnavailable')
3498
+ : t('petPreviewHint');
3499
+ return h(
3500
+ 'div',
3501
+ { className: 'owc-preview-tools' },
3502
+ h(
3503
+ 'div',
3504
+ {
3505
+ className: 'owc-preview-actions',
3506
+ role: 'group',
3507
+ 'aria-label': t('petPreviewActions'),
3508
+ },
3509
+ ...Object.keys(labels)
3510
+ .filter((action) => Object.hasOwn(clips, action))
3511
+ .map((action) =>
3512
+ h(
3513
+ 'button',
3514
+ {
3515
+ type: 'button',
3516
+ key: action,
3517
+ 'aria-label': t(names[action]),
3518
+ 'aria-pressed':
3519
+ previewState.action === action &&
3520
+ ['loading', 'playing'].includes(previewState.phase),
3521
+ disabled: blocked,
3522
+ onClick: () => previewMotion.current?.retry?.(action),
3523
+ },
3524
+ t(labels[action]),
3525
+ ),
2395
3526
  ),
3527
+ h(
3528
+ 'button',
3529
+ {
3530
+ type: 'button',
3531
+ 'aria-label': t('petStopPreview'),
3532
+ disabled: !['loading', 'playing'].includes(previewState.phase),
3533
+ onClick: () => previewMotion.current?.stop?.(),
3534
+ },
3535
+ t('petPreviewStop'),
2396
3536
  ),
2397
3537
  ),
3538
+ h(
3539
+ 'p',
3540
+ { className: 'owc-hint owc-preview-status', role: 'status', 'aria-atomic': true },
3541
+ message,
3542
+ ),
2398
3543
  );
3544
+ };
2399
3545
  const editor = () => {
2400
3546
  if (appearanceOnly)
2401
3547
  return h(
2402
3548
  'div',
2403
3549
  { className: 'owc-form owc-appearance', 'data-appearance-settings': true },
2404
- h('p', { className: 'owc-hint' }, t('petAppearancePreview')),
3550
+ h('p', { className: 'owc-sr-only' }, t('petAppearancePreview')),
2405
3551
  h(
2406
3552
  'div',
2407
- { 'data-appearance-preview': true, className: 'owc-appearance-preview' },
3553
+ {
3554
+ ref: previewBox,
3555
+ 'data-appearance-preview': true,
3556
+ style: features.cropping ? { display: 'none' } : undefined,
3557
+ className: 'owc-appearance-preview',
3558
+ },
2408
3559
  view.companion
2409
3560
  ? petArtwork(
2410
3561
  h,
2411
- companion,
3562
+ { pose: 'calm', animate: false },
2412
3563
  view.petSize * (petModel(view.petModel).renderer === 'svg' ? 1 : 0.65),
2413
3564
  '',
2414
- view.petModel,
2415
- PET_SPRITES,
3565
+ features.previewImage ? 'custom-image' : view.petModel,
3566
+ { ...PET_SPRITES, 'custom-image': features.previewImage },
3567
+ !features.previewImage && petModel(view.petModel).renderer === 'video',
2416
3568
  )
2417
3569
  : h('p', null, t('petClassicPreview')),
2418
3570
  ),
3571
+ !features.previewImage && previewActions(),
2419
3572
  appearanceFields(),
3573
+ features.render('appearance'),
2420
3574
  h(
2421
3575
  'div',
2422
3576
  { className: 'owc-save' },
2423
3577
  h('button', { type: 'button', onClick: closeEditor }, t('cancel')),
2424
3578
  h(
2425
3579
  'button',
2426
- { type: 'button', className: 'owc-primary', onClick: applyAppearance },
2427
- t('petApplyAppearance'),
3580
+ {
3581
+ type: 'button',
3582
+ className: 'owc-primary',
3583
+ 'aria-label': t('petApplyAppearance'),
3584
+ onClick: applyAppearance,
3585
+ },
3586
+ t('detailApply'),
2428
3587
  ),
2429
3588
  ),
2430
3589
  );
@@ -2432,21 +3591,7 @@ window.__ModuleLoader__.load({
2432
3591
  const payDaily = workingDraft.payMode === 'daily';
2433
3592
  const salaryKey = payDaily ? 'dailySalary' : 'monthlySalary';
2434
3593
  const salaryControl = view.masked
2435
- ? h(
2436
- 'div',
2437
- { className: 'owc-private' },
2438
- h('span', null, '••••'),
2439
- h(
2440
- 'button',
2441
- {
2442
- type: 'button',
2443
- className: 'owc-text',
2444
- 'aria-label': t('show'),
2445
- onClick: () => setPreference({ masked: false }),
2446
- },
2447
- t('show'),
2448
- ),
2449
- )
3594
+ ? h('div', { className: 'owc-private', 'aria-label': t('masked') }, '••••')
2450
3595
  : numberInput(salaryKey);
2451
3596
  const breakOn = workingDraft.breakStart !== workingDraft.breakEnd;
2452
3597
  const commonZones = [
@@ -2471,6 +3616,20 @@ window.__ModuleLoader__.load({
2471
3616
  const zones = [
2472
3617
  ...new Set([workingDraft.timezone, ...commonZones, ...allZones].filter(Boolean)),
2473
3618
  ];
3619
+ const timezoneField = () =>
3620
+ label(
3621
+ t('timezone'),
3622
+ h(
3623
+ 'select',
3624
+ {
3625
+ name: 'timezone',
3626
+ value: workingDraft.timezone ?? '',
3627
+ disabled: busy,
3628
+ onChange: (e) => update('timezone', e.target.value),
3629
+ },
3630
+ zones.map((zone) => h('option', { key: zone, value: zone }, zone)),
3631
+ ),
3632
+ );
2474
3633
  return h(
2475
3634
  'form',
2476
3635
  {
@@ -2487,7 +3646,22 @@ window.__ModuleLoader__.load({
2487
3646
  h(
2488
3647
  'fieldset',
2489
3648
  { className: 'owc-setting-group' },
2490
- h('legend', null, t('salaryGroup')),
3649
+ h(
3650
+ 'legend',
3651
+ { className: 'owc-salary-legend' },
3652
+ h('span', null, t('salaryGroup')),
3653
+ h(
3654
+ 'button',
3655
+ {
3656
+ type: 'button',
3657
+ className: 'owc-icon',
3658
+ 'aria-label': t(view.masked ? 'show' : 'hide'),
3659
+ title: t(view.masked ? 'show' : 'hide'),
3660
+ onClick: () => setPreference({ masked: !view.masked }),
3661
+ },
3662
+ iconSvg(view.masked ? 'eyeOff' : 'eye', 13),
3663
+ ),
3664
+ ),
2491
3665
  label(
2492
3666
  t('payMode'),
2493
3667
  h(
@@ -2581,22 +3755,10 @@ window.__ModuleLoader__.load({
2581
3755
  ),
2582
3756
  ),
2583
3757
  ),
2584
- label(
2585
- t('timezone'),
2586
- h(
2587
- 'select',
2588
- {
2589
- name: 'timezone',
2590
- value: workingDraft.timezone ?? '',
2591
- disabled: busy,
2592
- onChange: (e) => update('timezone', e.target.value),
2593
- },
2594
- zones.map((zone) => h('option', { key: zone, value: zone }, zone)),
2595
- ),
2596
- ),
2597
3758
  ),
2598
3759
  preview &&
2599
3760
  !preview.error &&
3761
+ !view.masked &&
2600
3762
  h(
2601
3763
  'div',
2602
3764
  { className: 'owc-preview' },
@@ -2620,8 +3782,7 @@ window.__ModuleLoader__.load({
2620
3782
  'details',
2621
3783
  { className: 'owc-advanced' },
2622
3784
  h('summary', null, t('advanced')),
2623
- h('p', { className: 'owc-hint' }, t('sessionHint')),
2624
- label(t('session'), sessionSelect()),
3785
+ timezoneField(),
2625
3786
  ),
2626
3787
  h('p', { className: 'owc-hint' }, t('privacy')),
2627
3788
  h(
@@ -2642,12 +3803,18 @@ window.__ModuleLoader__.load({
2642
3803
  );
2643
3804
  };
2644
3805
 
2645
- const notices = () =>
3806
+ const notices = (showBusinessError = true) =>
2646
3807
  h(
2647
3808
  React.Fragment,
2648
3809
  null,
2649
- error && h('p', { className: 'owc-error', role: 'alert' }, error),
2650
- state?.error && h('p', { className: 'owc-error', role: 'alert' }, state.error),
3810
+ showBusinessError &&
3811
+ !LOCAL_SCREENS.includes(ui.screen) &&
3812
+ error &&
3813
+ h('p', { className: 'owc-error', role: 'alert' }, error),
3814
+ showBusinessError &&
3815
+ !LOCAL_SCREENS.includes(ui.screen) &&
3816
+ state?.error &&
3817
+ h('p', { className: 'owc-error', role: 'alert' }, state.error),
2651
3818
  notice && h('p', { className: 'owc-note', role: 'status' }, notice),
2652
3819
  storageError && h('p', { className: 'owc-note' }, t('storage')),
2653
3820
  );
@@ -2685,8 +3852,15 @@ window.__ModuleLoader__.load({
2685
3852
  waterPrompt ? t('waterDue') : waterNotice ? t(waterNotice.key) : '',
2686
3853
  ),
2687
3854
  );
3855
+ React.useLayoutEffect(() => {
3856
+ if (!ready) return;
3857
+ const scroll = petMode
3858
+ ? petPanel.current?.querySelector('.owc-pet-scroll')
3859
+ : box.current?.querySelector('.owc-content');
3860
+ if (scroll) scroll.scrollTop = 0;
3861
+ }, [ready, petMode, ui.screen]);
2688
3862
  const petShowing = petOpen;
2689
- const petFloating = petShowing || waterPrompt;
3863
+ const petFloating = petShowing || autoWaterPrompt || features.alert;
2690
3864
  React.useLayoutEffect(() => {
2691
3865
  if (!petMode || !petFloating || !cardContent.current) return;
2692
3866
  const content = cardContent.current,
@@ -2775,22 +3949,46 @@ window.__ModuleLoader__.load({
2775
3949
  return () => window.removeEventListener('blur', blur);
2776
3950
  }, [ui.menu]);
2777
3951
  React.useEffect(() => {
2778
- if (!petMode || (!petShowing && !ui.menu)) return;
3952
+ if (!petShowing && !ui.menu) return;
2779
3953
  const outside = (event) => {
3954
+ if (ui.menu && event.target.closest?.('[data-quick-more],[data-panel-more]')) return;
2780
3955
  if (ui.menu && !menuBox.current?.contains(event.target)) {
2781
3956
  dispatchUI({ type: 'dismiss-menu' });
2782
3957
  return;
2783
3958
  }
2784
3959
  if (box.current?.contains(event.target)) return;
2785
3960
  if (ui.menu) dispatchUI({ type: 'dismiss-menu' });
2786
- else if (!editing && !waterEditing && !waterBusy)
2787
- dispatchUI({ type: 'navigate', screen: null });
3961
+ else if (
3962
+ petMode &&
3963
+ !editing &&
3964
+ !waterEditing &&
3965
+ !waterBusy &&
3966
+ !features.dirty &&
3967
+ !features.busy
3968
+ )
3969
+ navigateScreen(null);
2788
3970
  };
2789
3971
  document.addEventListener('pointerdown', outside);
2790
3972
  return () => document.removeEventListener('pointerdown', outside);
2791
- }, [petMode, petShowing, ui.menu, editing, waterEditing, waterBusy]);
3973
+ }, [
3974
+ petMode,
3975
+ petShowing,
3976
+ ui.menu,
3977
+ ui.screen,
3978
+ editing,
3979
+ waterEditing,
3980
+ waterBusy,
3981
+ features.dirty,
3982
+ features.busy,
3983
+ ]);
2792
3984
  React.useEffect(() => {
2793
- if (ui.menu) menuBox.current?.querySelector('[role=menuitem]:not(:disabled)')?.focus();
3985
+ if (!ui.menu) return;
3986
+ const waterEntry = menuTrigger.current?.classList.contains('owc-pet-water-alert')
3987
+ ? menuBox.current?.querySelector('[data-pet-menu-screen="home"]:not(:disabled)')
3988
+ : null;
3989
+ (
3990
+ waterEntry || menuBox.current?.querySelector('[role=menuitem]:not(:disabled)')
3991
+ )?.focus();
2794
3992
  }, [ui.menu]);
2795
3993
  React.useEffect(() => {
2796
3994
  if (!petMode || editing || waterEditing || ui.menu) return;
@@ -2806,6 +4004,32 @@ window.__ModuleLoader__.load({
2806
4004
  if (ui.pending && (busy || waterBusy))
2807
4005
  box.current?.querySelector('[data-nav-confirm]')?.focus();
2808
4006
  }, [ui.pending, busy, waterBusy]);
4007
+ const classicNavigation = React.useRef({ screen: ui.screen, pending: !!ui.pending });
4008
+ React.useEffect(() => {
4009
+ const previous = classicNavigation.current;
4010
+ classicNavigation.current = { screen: ui.screen, pending: !!ui.pending };
4011
+ if (
4012
+ petMode ||
4013
+ !ready ||
4014
+ (previous.screen === ui.screen && previous.pending === !!ui.pending)
4015
+ )
4016
+ return;
4017
+ const frame = requestAnimationFrame(() => {
4018
+ if (ui.pending) box.current?.querySelector('[data-keep-editing]')?.focus();
4019
+ else if (!editing && !waterEditing) {
4020
+ const kind = focusReturn.current?.getAttribute?.('data-classic-timer');
4021
+ const target =
4022
+ !ui.screen && ['water', 'smoking'].includes(kind)
4023
+ ? box.current?.querySelector(`[data-classic-timer="${kind}"]`)
4024
+ : box.current?.querySelector(
4025
+ '.owc-classic-head .owc-title,.owc-classic-mini .owc-grip',
4026
+ );
4027
+ target?.focus();
4028
+ } else if (previous.pending)
4029
+ box.current?.querySelector('form input,form select,form button')?.focus();
4030
+ });
4031
+ return () => cancelAnimationFrame(frame);
4032
+ }, [petMode, ready, ui.screen, !!ui.pending]);
2809
4033
  const draftGuard = () =>
2810
4034
  h(
2811
4035
  'div',
@@ -2867,12 +4091,28 @@ window.__ModuleLoader__.load({
2867
4091
  );
2868
4092
  const petPageTitle = {
2869
4093
  home: 'petWaterCard',
4094
+ 'water-countdown': 'waterCountdown',
4095
+ smoking: 'smokingCountdown',
2870
4096
  income: 'petCountdownCard',
2871
4097
  earnings: 'petEarningsCard',
2872
4098
  water: 'petWaterSettings',
2873
4099
  appearance: 'petAppearance',
2874
4100
  schedule: 'petWorkSettings',
4101
+ wrap: 'companionWrapTitle',
4102
+ preferences: 'companionPreferencesTitle',
4103
+ data: 'companionDataTitle',
4104
+ reminders: 'companionRemindersTitle',
2875
4105
  };
4106
+ const pageTitle = (screen) =>
4107
+ screen === 'income' && safeState
4108
+ ? safeState.status === 'before'
4109
+ ? t('petCountdownBefore')
4110
+ : safeState.status === 'done'
4111
+ ? t('classicMiniFinished')
4112
+ : safeState.status === 'rest'
4113
+ ? t('classicMiniRest')
4114
+ : t('petCountdownCard')
4115
+ : t(petPageTitle[screen] || 'waterTitle');
2876
4116
  const petHeader = () =>
2877
4117
  h(
2878
4118
  'div',
@@ -2893,8 +4133,46 @@ window.__ModuleLoader__.load({
2893
4133
  ),
2894
4134
  h(
2895
4135
  'strong',
2896
- { 'data-pet-page-title': true, tabIndex: -1, style: { flex: 1, fontSize: 12 } },
2897
- t(petPageTitle[ui.screen] || 'waterTitle'),
4136
+ { 'data-pet-page-title': true, tabIndex: -1, style: { flex: 1 } },
4137
+ pageTitle(ui.screen),
4138
+ ),
4139
+ ui.screen === 'income' &&
4140
+ data?.configured &&
4141
+ h(
4142
+ 'button',
4143
+ {
4144
+ type: 'button',
4145
+ className: 'owc-icon',
4146
+ 'aria-label': t('petWorkSettings'),
4147
+ title: t('petWorkSettings'),
4148
+ disabled: busy || !!ui.pending,
4149
+ onClick: openEditor,
4150
+ },
4151
+ iconSvg('sliders', 14),
4152
+ ),
4153
+ ['home', 'water-countdown'].includes(ui.screen) &&
4154
+ h(
4155
+ 'button',
4156
+ {
4157
+ type: 'button',
4158
+ className: 'owc-icon',
4159
+ 'aria-label': t('petWaterSettings'),
4160
+ title: t('petWaterSettings'),
4161
+ disabled: busy || waterBusy || !!ui.pending,
4162
+ onClick: openWaterEditor,
4163
+ },
4164
+ iconSvg('sliders', 14),
4165
+ ),
4166
+ h(
4167
+ 'button',
4168
+ {
4169
+ type: 'button',
4170
+ className: 'owc-icon',
4171
+ 'data-panel-more': true,
4172
+ 'aria-label': t('quickMore'),
4173
+ onClick: openQuickMenu,
4174
+ },
4175
+ '⋯',
2898
4176
  ),
2899
4177
  h(
2900
4178
  'button',
@@ -2911,102 +4189,115 @@ window.__ModuleLoader__.load({
2911
4189
  const petHome = () =>
2912
4190
  h(
2913
4191
  'div',
2914
- { className: 'owc-pet-home', 'data-pet-home': true },
2915
-
2916
- h(
2917
- 'div',
2918
- {
2919
- className: 'owc-pet-water-card' + (waterPrompt ? ' owc-water-due' : ''),
2920
- 'data-water-event': waterPrompt ? waterState.eventId : undefined,
2921
- },
4192
+ {
4193
+ className: 'owc-pet-home' + (waterPrompt ? ' owc-water-due' : ''),
4194
+ 'data-pet-home': true,
4195
+ 'data-water-event': waterPrompt ? waterState.eventId : undefined,
4196
+ },
4197
+ waterHero(),
4198
+ waterState.phase === 'due' &&
4199
+ features.waterSnoozedUntil > now &&
2922
4200
  h(
2923
4201
  'p',
2924
- { className: 'owc-water-status', 'data-water-status': true },
2925
- waterPrompt
2926
- ? t('waterDue')
2927
- : waterState.phase === 'quiet'
2928
- ? t('petQuietActive')
2929
- : waterSummary(),
4202
+ { className: 'owc-hint' },
4203
+ t('waterSnooze') +
4204
+ ' · ' +
4205
+ formatDuration(Math.ceil((features.waterSnoozedUntil - now) / 1000)),
2930
4206
  ),
4207
+ features.quiet &&
2931
4208
  h(
2932
- 'div',
2933
- { className: 'owc-pet-water-row' },
4209
+ 'p',
4210
+ { className: 'owc-hint' },
4211
+ t('waterQuiet') +
4212
+ ' · ' +
4213
+ formatDuration(Math.ceil((features.state.preferences.quietUntil - now) / 1000)),
4214
+ ),
4215
+ h(
4216
+ 'div',
4217
+ { className: 'owc-detail-primary-actions' },
4218
+ h(
4219
+ 'button',
4220
+ {
4221
+ type: 'button',
4222
+ className: 'owc-primary owc-water-record',
4223
+ disabled: !waterCanRecord || waterBusy,
4224
+ onClick: recordWater,
4225
+ },
4226
+ t('petRecord'),
4227
+ ),
4228
+ waterState.phase === 'due' &&
4229
+ features.waterSnoozedUntil <= now &&
2934
4230
  h(
2935
4231
  'button',
2936
4232
  {
2937
4233
  type: 'button',
2938
- className: 'owc-primary',
2939
- disabled: !waterCanRecord || waterBusy,
2940
- onClick: recordWater,
4234
+ className: 'owc-text',
4235
+ disabled: waterBusy || features.busy || !features.waterCanSnooze,
4236
+ onClick: () => features.snoozeWater(),
2941
4237
  },
2942
- t('petRecord'),
2943
- ),
2944
- h(
2945
- 'span',
2946
- { 'data-water-count': true },
2947
- t('waterCount') + ': ' + waterState.count,
4238
+ t('waterSnooze'),
2948
4239
  ),
2949
- ),
2950
4240
  ),
2951
4241
  h(
2952
4242
  'div',
2953
- { className: 'owc-pet-quiet-row' },
2954
- [30, 60].map((minutes) =>
4243
+ { className: 'owc-detail-secondary' },
4244
+ h(
4245
+ 'button',
4246
+ {
4247
+ type: 'button',
4248
+ className: 'owc-text owc-secondary',
4249
+ disabled: busy || waterBusy || waterError || !waterReady,
4250
+ onClick: () =>
4251
+ waterAction(
4252
+ water.enabled
4253
+ ? { type: water.paused ? 'resume' : 'pause' }
4254
+ : { type: 'preferences', enabled: true },
4255
+ ),
4256
+ },
4257
+ t(!water.enabled ? 'waterEnable' : water.paused ? 'waterResume' : 'waterPause'),
4258
+ ),
4259
+ waterState.phase === 'quiet' &&
2955
4260
  h(
2956
4261
  'button',
2957
4262
  {
2958
4263
  type: 'button',
2959
- key: minutes,
2960
- disabled: !waterCanQuiet || waterBusy,
2961
- onClick: () => waterAction({ type: 'quiet', minutes }),
4264
+ className: 'owc-text',
4265
+ disabled: waterBusy,
4266
+ onClick: () => waterAction({ type: 'end-quiet' }),
2962
4267
  },
2963
- t(minutes === 30 ? 'petQuiet30' : 'petQuiet60'),
4268
+ t('waterQuietEnd'),
2964
4269
  ),
2965
- ),
2966
- ),
2967
- h(
2968
- 'div',
2969
- { className: 'owc-pet-quiet-status' },
2970
- waterPrompt
2971
- ? h(
2972
- 'button',
2973
- {
2974
- type: 'button',
2975
- disabled: waterBusy,
2976
- onClick: () => waterAction({ type: 'snooze', eventId: waterState.eventId }),
2977
- },
2978
- t('waterSnooze'),
2979
- )
2980
- : water.shift?.paused
2981
- ? t('petPausedHint')
2982
- : waterState.phase === 'quiet'
2983
- ? h(
2984
- React.Fragment,
2985
- null,
2986
- t('waterQuiet') +
2987
- ' · ' +
2988
- formatDuration(waterState.quietRemaining / 1000),
2989
- h(
2990
- 'button',
2991
- {
2992
- type: 'button',
2993
- disabled: waterBusy,
2994
- onClick: () => waterAction({ type: 'end-quiet' }),
2995
- },
2996
- t('waterQuietEnd'),
2997
- ),
2998
- )
2999
- : t('petQuietHint'),
3000
- ),
3001
- h(
3002
- 'div',
3003
- { className: 'owc-pet-feedback' },
3004
- waterNotice
3005
- ? t(waterNotice.key)
3006
- : waterError
3007
- ? t('waterUnavailable')
3008
- : t('petHomeHint'),
3009
4270
  ),
4271
+ water.enabled &&
4272
+ h(
4273
+ 'details',
4274
+ { className: 'owc-detail-more' },
4275
+ h('summary', null, t('waterQuiet')),
4276
+ h(
4277
+ 'div',
4278
+ { className: 'owc-detail-options' },
4279
+ [30, 60].map((minutes) =>
4280
+ h(
4281
+ 'button',
4282
+ {
4283
+ type: 'button',
4284
+ key: minutes,
4285
+ disabled: !waterCanQuiet || waterBusy,
4286
+ onClick: (event) => {
4287
+ const menu = event.currentTarget.closest('details');
4288
+ menu.open = false;
4289
+ menu.querySelector('summary')?.focus();
4290
+ void waterAction({ type: 'quiet', minutes });
4291
+ },
4292
+ },
4293
+ t(minutes === 30 ? 'petQuiet30' : 'petQuiet60'),
4294
+ ),
4295
+ ),
4296
+ ),
4297
+ h('p', { className: 'owc-hint' }, t('petQuietHint')),
4298
+ ),
4299
+ waterError &&
4300
+ h('p', { className: 'owc-error', role: 'alert' }, t('waterUnavailable')),
3010
4301
  waterError &&
3011
4302
  h(
3012
4303
  'button',
@@ -3017,27 +4308,116 @@ window.__ModuleLoader__.load({
3017
4308
  },
3018
4309
  t('waterRetry'),
3019
4310
  ),
4311
+ );
4312
+ function openQuickMenu(event) {
4313
+ if (ui.pending || busy || waterBusy || features.busy) return;
4314
+ if (ui.menu) {
4315
+ closePetMenu();
4316
+ return;
4317
+ }
4318
+ menuTrigger.current = event.currentTarget;
4319
+ const rect = event.currentTarget.getBoundingClientRect();
4320
+ dispatchUI({ type: 'menu', position: { x: rect.right, y: rect.top } });
4321
+ }
4322
+ const activeCustomTimer = () =>
4323
+ features.activeTimer &&
4324
+ h(
4325
+ 'button',
4326
+ {
4327
+ type: 'button',
4328
+ className: 'owc-inline-timer',
4329
+ 'data-active-custom-timer': true,
4330
+ onClick: (event) => navigateScreen('smoking', event),
4331
+ title: features.activeTimer.name,
4332
+ },
4333
+ iconSvg('alarm', 12),
4334
+ h('span', null, features.activeTimer.name),
3020
4335
  h(
3021
- 'div',
3022
- { className: 'owc-pet-home-links' },
3023
- h('button', { type: 'button', onClick: openWaterEditor }, t('petWaterSettings')),
4336
+ 'strong',
4337
+ null,
4338
+ (features.activeTimer.phase === 'paused' ? 'Ⅱ ' : '') +
4339
+ formatDuration(Math.ceil(features.activeTimer.remaining / 1000)).replace(
4340
+ /^00:/,
4341
+ '',
4342
+ ),
4343
+ ),
4344
+ features.activeTimer.count > 1 ? ' +' + (features.activeTimer.count - 1) : null,
4345
+ );
4346
+ const quickActions = () =>
4347
+ h(
4348
+ 'div',
4349
+ { className: 'owc-quick-actions' },
4350
+ waterState.phase === 'due' &&
4351
+ h(
4352
+ 'button',
4353
+ {
4354
+ type: 'button',
4355
+ 'data-quick-drink': true,
4356
+ disabled: !waterCanRecord || waterBusy,
4357
+ onClick: recordWater,
4358
+ },
4359
+ iconSvg('water', 13),
4360
+ waterNotice?.kind === 'drink' ? t('waterRecorded') : t('petRecord'),
4361
+ ),
4362
+ features.pendingCount > 0 &&
3024
4363
  h(
3025
4364
  'button',
3026
4365
  {
3027
4366
  type: 'button',
3028
- disabled:
3029
- !water.enabled ||
3030
- busy ||
3031
- waterBusy ||
3032
- waterError ||
3033
- !waterContext ||
3034
- water.shift?.id !== waterContext.id,
3035
- onClick: () => waterAction({ type: water.shift?.paused ? 'resume' : 'pause' }),
4367
+ 'aria-label': t('companionRemindersTitle'),
4368
+ onClick: (event) => navigateScreen('reminders', event),
3036
4369
  },
3037
- t(water.shift?.paused ? 'waterResume' : 'waterPause'),
4370
+ iconSvg('alarm', 13),
4371
+ String(features.pendingCount),
3038
4372
  ),
4373
+ h(
4374
+ 'button',
4375
+ {
4376
+ type: 'button',
4377
+ className: 'owc-quick-more',
4378
+ 'data-quick-more': true,
4379
+ 'aria-label': t('quickMore'),
4380
+ 'aria-expanded': !!ui.menu,
4381
+ onClick: openQuickMenu,
4382
+ },
4383
+ '⋯',
3039
4384
  ),
3040
4385
  );
4386
+ const contentForScreen = () => {
4387
+ if (ui.pending) return draftGuard();
4388
+ if (waterEditing) return waterSettings();
4389
+ if (editing) return editor();
4390
+ if (ui.screen === 'income')
4391
+ return h(
4392
+ React.Fragment,
4393
+ null,
4394
+ panelBody('clock'),
4395
+ features.render('wrap-entry'),
4396
+ h(
4397
+ 'div',
4398
+ { className: 'owc-extra' },
4399
+ h(
4400
+ 'button',
4401
+ {
4402
+ type: 'button',
4403
+ className: 'owc-link-row',
4404
+ onClick: (event) => navigateScreen('earnings', event),
4405
+ disabled: !data?.configured,
4406
+ },
4407
+ t('petEarningsCard'),
4408
+ ),
4409
+ ),
4410
+ );
4411
+ if (ui.screen === 'smoking')
4412
+ return h(React.Fragment, null, smokingCountdownCard(), features.render('timers'));
4413
+ if (ui.screen === 'water-countdown' || ui.screen === 'home') return petHome();
4414
+ if (ui.screen === 'earnings') return panelBody('earnings');
4415
+ if (ui.screen === 'wrap') return features.render('wrap');
4416
+ if (ui.screen === 'preferences') return features.render('settings');
4417
+ if (ui.screen === 'data') return features.render('data');
4418
+ if (ui.screen === 'reminders') return features.render('reminders');
4419
+ return collapsed ? classicMini() : classicOverview();
4420
+ };
3041
4421
  const contextMenu = () =>
3042
4422
  ui.menu &&
3043
4423
  h(
@@ -3091,9 +4471,9 @@ window.__ModuleLoader__.load({
3091
4471
  [
3092
4472
  ['income', 'petCountdownCard'],
3093
4473
  ['home', 'petWaterCard'],
3094
- ['earnings', 'petEarningsCard'],
4474
+ ['smoking', 'smokingCountdown'],
3095
4475
  ['appearance', 'petAppearance'],
3096
- ['schedule', 'petWorkSettings'],
4476
+ ['preferences', 'companionPreferencesTitle'],
3097
4477
  ].map(([screen, label]) =>
3098
4478
  h(
3099
4479
  'button',
@@ -3101,14 +4481,34 @@ window.__ModuleLoader__.load({
3101
4481
  key: screen,
3102
4482
  type: 'button',
3103
4483
  role: 'menuitem',
4484
+ 'data-pet-menu-screen': screen,
3104
4485
  tabIndex: -1,
3105
- disabled: busy || waterBusy,
4486
+ disabled:
4487
+ requestBusy.current ||
4488
+ waterBusy ||
4489
+ (!LOCAL_SCREENS.includes(screen) &&
4490
+ (hostBusy || !data || (!data.configured && screen !== 'schedule'))),
3106
4491
  onClick: (event) => navigateScreen(screen, event),
3107
4492
  },
3108
4493
  t(label),
3109
4494
  ),
3110
4495
  ),
3111
4496
 
4497
+ (!data || error || state?.error) &&
4498
+ h(
4499
+ 'button',
4500
+ {
4501
+ type: 'button',
4502
+ role: 'menuitem',
4503
+ tabIndex: -1,
4504
+ disabled: hostBusy,
4505
+ onClick: () => {
4506
+ closePetMenu();
4507
+ void reload(true);
4508
+ },
4509
+ },
4510
+ t('retry'),
4511
+ ),
3112
4512
  h('hr', null),
3113
4513
  h(
3114
4514
  'button',
@@ -3126,17 +4526,26 @@ window.__ModuleLoader__.load({
3126
4526
  ),
3127
4527
  );
3128
4528
 
4529
+ if (!ready) return null;
3129
4530
  if (petMode)
3130
4531
  return h(
3131
4532
  'section',
3132
4533
  {
3133
4534
  ref: box,
3134
4535
  className: 'owc-v2 owc-pet-shell',
3135
- 'data-version': '1.1.1',
4536
+ 'data-version': '1.2.2',
3136
4537
  'data-pet-model': stageModel,
3137
- 'data-status': safeState.status,
4538
+ 'data-status':
4539
+ error || state?.error
4540
+ ? 'error'
4541
+ : data?.configured && safeState
4542
+ ? safeState.status
4543
+ : data
4544
+ ? 'setup'
4545
+ : 'loading',
3138
4546
  'aria-label': t('companionLabel'),
3139
4547
  style: {
4548
+ ...featureStyle,
3140
4549
  '--owc-alpha': String(view.opacity),
3141
4550
  left: petGeometry.x,
3142
4551
  top: petGeometry.y,
@@ -3160,11 +4569,11 @@ window.__ModuleLoader__.load({
3160
4569
  else if (ui.pending) dispatchUI({ type: 'stay' });
3161
4570
  else if (editing || waterEditing) navigateScreen(ui.back);
3162
4571
  else if (petShowing) closePetPanel();
3163
- else if (waterPrompt)
3164
- void waterAction({ type: 'snooze', eventId: waterState.eventId });
4572
+ else if (features.alert) features.dismissAlert();
4573
+ else if (autoWaterPrompt) dismissWaterPopup();
3165
4574
  },
3166
4575
  },
3167
- h('style', null, STYLE),
4576
+ h('style', null, STYLE + COMPANION_APP_STYLE),
3168
4577
  liveFeedback(),
3169
4578
  h(
3170
4579
  'div',
@@ -3189,33 +4598,84 @@ window.__ModuleLoader__.load({
3189
4598
  petGeometry.artworkSize,
3190
4599
  waterNotice?.token,
3191
4600
  stageModel,
3192
- PET_SPRITES,
4601
+ { ...PET_SPRITES, 'custom-image': features.customImage },
3193
4602
  true,
3194
4603
  ),
3195
4604
  ),
3196
4605
  h(
3197
4606
  'div',
3198
4607
  {
3199
- className: 'owc-pet-clock',
3200
- role: 'timer',
3201
- 'aria-label': t('petClock'),
4608
+ className: 'owc-pet-timers',
4609
+ role: 'group',
4610
+ 'aria-label': t('petCountdownGroup'),
3202
4611
  },
3203
- iconSvg('alarm', 12),
3204
- h('span', { className: 'owc-pet-clock-label' }, t(safeState.status)),
3205
- ['done', 'rest'].includes(safeState.status) ? null : h('strong', null, duration),
4612
+ h(
4613
+ 'button',
4614
+ {
4615
+ type: 'button',
4616
+ onClick: (event) =>
4617
+ navigateScreen(data?.configured ? 'income' : 'schedule', event),
4618
+ className: 'owc-pet-clock',
4619
+ 'aria-label': t('petCountdownCard'),
4620
+ title: error || state?.error ? t('petRetryHint') : undefined,
4621
+ },
4622
+ iconSvg('alarm', 12),
4623
+ h(
4624
+ 'span',
4625
+ { className: 'owc-pet-clock-label', title: t(petClockStatus) },
4626
+ t(petClockStatus),
4627
+ ),
4628
+ data?.configured &&
4629
+ safeState &&
4630
+ !error &&
4631
+ !['done', 'rest'].includes(safeState.status)
4632
+ ? h('strong', null, duration)
4633
+ : null,
4634
+ ),
4635
+ h(
4636
+ 'div',
4637
+ { className: 'owc-pet-timer-pair' },
4638
+ timerRow(
4639
+ 'water',
4640
+ waterCountdownText(),
4641
+ waterState.phase === 'due',
4642
+ t('waterCountdown') + ' · ' + waterSummary(),
4643
+ ),
4644
+ timerRow(
4645
+ 'smoking',
4646
+ smokingCountdownText(),
4647
+ smokingState.phase === 'due',
4648
+ t('smokingCountdown') +
4649
+ ' · ' +
4650
+ (smokingUnavailable
4651
+ ? t('timerUnavailable')
4652
+ : t(
4653
+ {
4654
+ idle: 'timerIdle',
4655
+ running: 'timerRunning',
4656
+ paused: 'timerPaused',
4657
+ due: 'timerDue',
4658
+ }[smokingState.phase],
4659
+ )),
4660
+ ),
4661
+ ),
4662
+ activeCustomTimer(),
4663
+ quickActions(),
3206
4664
  ),
3207
4665
  ),
3208
4666
  waterPrompt &&
3209
- petShowing &&
4667
+ !autoWaterPrompt &&
3210
4668
  ui.screen !== 'home' &&
3211
4669
  h(
3212
- 'div',
4670
+ 'button',
3213
4671
  {
4672
+ type: 'button',
3214
4673
  className: 'owc-pet-water-alert owc-water-due',
3215
4674
  'data-water-event': waterState.eventId,
3216
- role: 'note',
3217
4675
  'aria-label': t('petWaterAlert'),
3218
4676
  title: t('petWaterAlert'),
4677
+ disabled: busy || waterBusy || !!ui.pending,
4678
+ onClick: openWaterPopup,
3219
4679
  style: {
3220
4680
  left: waterAlertLayout.left,
3221
4681
  top: waterAlertLayout.top,
@@ -3224,7 +4684,8 @@ window.__ModuleLoader__.load({
3224
4684
  },
3225
4685
  },
3226
4686
  iconSvg('water', 13),
3227
- t('petWaterBadge'),
4687
+ h('span', { className: 'owc-pet-water-label' }, t('petWaterBadge')),
4688
+ iconSvg('chevronUp', 10),
3228
4689
  ),
3229
4690
  contextMenu(),
3230
4691
  !petShowing &&
@@ -3249,12 +4710,15 @@ window.__ModuleLoader__.load({
3249
4710
  'div',
3250
4711
  {
3251
4712
  ref: petPanel,
3252
- className: 'owc-pet-panel owc-pet-workspace',
3253
- 'data-pet-panel': petShowing ? 'open' : 'bubble',
4713
+ className: 'owc-pet-panel owc-pet-workspace owc-detail-page',
4714
+ 'data-pet-panel': petShowing ? 'open' : 'reminder',
3254
4715
  'data-pet-screen': ui.screen || 'reminder',
3255
4716
  'data-side': floatingPanel.side,
3256
4717
  role: 'region',
3257
- 'aria-label': t(petPageTitle[ui.screen] || 'waterTitle'),
4718
+ 'aria-label':
4719
+ features.alert && !petShowing
4720
+ ? features.alertTitle
4721
+ : t(petPageTitle[ui.screen] || 'waterTitle'),
3258
4722
  style: {
3259
4723
  left: floatingPanel.left,
3260
4724
  top: floatingPanel.top,
@@ -3269,16 +4733,21 @@ window.__ModuleLoader__.load({
3269
4733
  : h(
3270
4734
  'div',
3271
4735
  { className: 'owc-head' },
3272
- h('strong', { style: { flex: 1, fontSize: 12 } }, t('waterTitle')),
4736
+ h(
4737
+ 'strong',
4738
+ { style: { flex: 1, fontSize: 12 } },
4739
+ features.alert ? features.alertTitle : t('waterTitle'),
4740
+ ),
3273
4741
  h(
3274
4742
  'button',
3275
4743
  {
3276
4744
  type: 'button',
3277
4745
  className: 'owc-icon',
3278
- 'aria-label': t('waterSnooze'),
4746
+ 'aria-label': t('petDismissReminder'),
4747
+ title: t('petDismissReminder'),
3279
4748
  disabled: waterBusy,
3280
4749
  onClick: () =>
3281
- waterAction({ type: 'snooze', eventId: waterState.eventId }),
4750
+ features.alert ? features.dismissAlert() : dismissWaterPopup(),
3282
4751
  },
3283
4752
  iconSvg('close', 14),
3284
4753
  ),
@@ -3293,20 +4762,16 @@ window.__ModuleLoader__.load({
3293
4762
  ? h(
3294
4763
  React.Fragment,
3295
4764
  null,
3296
- ui.pending
3297
- ? draftGuard()
3298
- : waterEditing
3299
- ? waterSettings()
3300
- : editing
3301
- ? editor()
3302
- : petDetails
3303
- ? panelBody('clock')
3304
- : ui.screen === 'earnings'
3305
- ? panelBody('earnings')
3306
- : petHome(),
4765
+ contentForScreen(),
3307
4766
  notices(),
4767
+ features.feedback(),
3308
4768
  )
3309
- : waterReminder(),
4769
+ : h(
4770
+ React.Fragment,
4771
+ null,
4772
+ features.alert ? features.render('alert') : waterReminder(),
4773
+ features.feedback(),
4774
+ ),
3310
4775
  ),
3311
4776
  ),
3312
4777
  ),
@@ -3317,13 +4782,14 @@ window.__ModuleLoader__.load({
3317
4782
  {
3318
4783
  ref: box,
3319
4784
  className:
3320
- 'owc-v2' +
4785
+ 'owc-v2 owc-classic' +
4786
+ (!collapsed ? ' owc-detail-page' : '') +
3321
4787
  (collapsed ? ' owc-compact' : '') +
3322
4788
  (collapsed && waterQuickOpen ? ' owc-quick' : '') +
3323
4789
  (editing || waterEditing ? ' owc-editing' : ''),
3324
4790
  'aria-label': t('title'),
3325
- 'data-version': '1.1.1',
3326
- 'data-status': safeState?.status || 'setup',
4791
+ 'data-version': '1.2.2',
4792
+ 'data-status': classicClock.phase,
3327
4793
  style: cardStyle,
3328
4794
  onPointerDown: dragDown,
3329
4795
  onPointerMove: dragMove,
@@ -3342,12 +4808,12 @@ window.__ModuleLoader__.load({
3342
4808
  return;
3343
4809
  event.preventDefault();
3344
4810
  if (ui.pending) dispatchUI({ type: 'stay' });
3345
- else if (editing || waterEditing) navigateScreen(ui.back);
3346
- else if (waterQuickOpen) closeWaterQuick();
4811
+ else if (ui.screen)
4812
+ navigateScreen(PET_EDITOR_SCREENS.includes(ui.screen) ? ui.back : null);
3347
4813
  else setPreference({ mini: true });
3348
4814
  },
3349
4815
  },
3350
- h('style', null, STYLE),
4816
+ h('style', null, STYLE + COMPANION_APP_STYLE),
3351
4817
  h('div', { className: 'owc-glass', 'aria-hidden': 'true' }),
3352
4818
  h(
3353
4819
  'div',
@@ -3365,33 +4831,14 @@ window.__ModuleLoader__.load({
3365
4831
  waterPrompt ? t('waterDue') : waterNotice ? t(waterNotice.key) : '',
3366
4832
  ),
3367
4833
  ),
3368
- collapsed
3369
- ? h(
3370
- React.Fragment,
3371
- null,
3372
- strip(),
3373
- waterQuickOpen && waterOverview(true),
3374
- !waterQuickOpen &&
3375
- waterNotice &&
3376
- (!view.companion || companion.announce) &&
3377
- h('p', { className: 'owc-mini-feedback' }, t(waterNotice.key)),
3378
- )
3379
- : h(
3380
- React.Fragment,
3381
- null,
3382
- panelHead(),
3383
- !editing && !waterEditing && companionCard(),
3384
- ui.pending
3385
- ? draftGuard()
3386
- : waterEditing
3387
- ? waterSettings()
3388
- : editing
3389
- ? editor()
3390
- : panelBody(),
3391
- notices(),
3392
- !editing && !waterEditing && waterOverview(),
3393
- ),
3394
- !collapsed && waterReminder(),
4834
+ !collapsed && classicHeader(),
4835
+ contentForScreen(),
4836
+ notices(ui.screen !== null),
4837
+ features.feedback(),
4838
+ ui.screen === null && (features.alert ? features.render('alert') : waterReminder()),
4839
+ ui.screen === null && activeCustomTimer(),
4840
+ ui.screen === null && quickActions(),
4841
+ contextMenu(),
3395
4842
  ),
3396
4843
  );
3397
4844
  }