dsh-offwork 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.source.js CHANGED
@@ -5,47 +5,267 @@ window.__ModuleLoader__.load({
5
5
  const React = require('react');
6
6
  const h = React.createElement;
7
7
  /* PURE_MODULES */
8
+ /* PET_SPRITES */
8
9
  const messages = {
9
10
  zh: {
10
- title:'下班计时器', working:'距离下班', before:'距离开工', lunch:'午休中', done:'本班已完成', rest:'今天休息',
11
- earned:'本班已赚 · 税前', daily:'日薪', hourly:'有效时薪', progress:'计薪进度',
12
- settings:'设置', collapse:'收起为小条', expand:'展开', hide:'隐藏工资', show:'显示工资',
13
- save:'保存', cancel:'取消', monthlySalary:'税前月薪(元)', dailySalary:'税前日薪(元)',
14
- workdaysPerMonth:'月计薪天数', start:'上班时间', end:'下班时间', breakStart:'休息开始', breakEnd:'休息结束',
15
- timezone:'时区', weekdays:'班次开始日', payMode:'计薪方式', monthly:'月薪', dailyMode:'日薪',
16
- loading:'正在读取设置…', retry:'重新读取', noSession:'请先打开或创建一个会话。', error:'操作失败,请重试。',
17
- saved:'设置已保存', days:'日一二三四五六', privacy:'配置存本机;读写结果进入所选会话记录。',
18
- drag:'按住可拖动;方向键微调,Home 复位', setup:'先设好你的作息', setupHint:'示例数值不是你的收入,保存后才开始计时。',
19
- breakEnabled:'扣除无薪休息', noBreak:'不扣休息', nextDay:'次日', preview:'折算',
20
- salaryGroup:'薪资', scheduleGroup:'作息', appearanceGroup:'外观', advanced:'高级设置', sessionHint:'仅用于记录配置读写操作,不是账本,不影响工资计算。', session:'操作记录会话', masked:'工资已隐藏', reset:'复位位置', synced:'已读取最新配置',
21
- refresh:'读取最新配置', busy:'保存中…', storage:'浏览器禁止本地存储;位置与透明度本次有效。',
22
- shiftDate:'班次', opacity:'背景透明度', opacityHint:'只影响背景,不影响文字。',
11
+ ...COMPANION_MESSAGES.zh,
12
+ ...PET_MESSAGES.zh,
13
+ ...PET_INTERACTION_MESSAGES.zh,
14
+ ...PET_SURFACE_MESSAGES.zh,
15
+ title: '下班计时器',
16
+ working: '距离下班',
17
+ before: '距离开工',
18
+ lunch: '午休中',
19
+ done: '本班已完成',
20
+ rest: '今天休息',
21
+ earned: '本班已赚 · 税前',
22
+ daily: '日薪',
23
+ hourly: '有效时薪',
24
+ progress: '计薪进度',
25
+ settings: '设置',
26
+ collapse: '收起为小条',
27
+ expand: '展开',
28
+ hide: '隐藏工资',
29
+ show: '显示工资',
30
+ save: '保存',
31
+ cancel: '取消',
32
+ monthlySalary: '税前月薪(元)',
33
+ dailySalary: '税前日薪(元)',
34
+ workdaysPerMonth: '月计薪天数',
35
+ start: '上班时间',
36
+ end: '下班时间',
37
+ breakStart: '休息开始',
38
+ breakEnd: '休息结束',
39
+ timezone: '时区',
40
+ weekdays: '班次开始日',
41
+ payMode: '计薪方式',
42
+ monthly: '月薪',
43
+ dailyMode: '日薪',
44
+ loading: '正在读取设置…',
45
+ retry: '重新读取',
46
+ noSession: '请先打开或创建一个会话。',
47
+ error: '操作失败,请重试。',
48
+ saved: '设置已保存',
49
+ days: '日一二三四五六',
50
+ privacy: '配置保存在 Harness 所在机器;读写结果进入所选会话记录。',
51
+ drag: '按住可拖动;方向键微调,Home 复位',
52
+ setup: '先设好你的作息',
53
+ setupHint: '示例数值不是你的收入,保存后才开始计时。',
54
+ breakEnabled: '扣除无薪休息',
55
+ noBreak: '不扣休息',
56
+ nextDay: '次日',
57
+ preview: '折算',
58
+ salaryGroup: '薪资',
59
+ scheduleGroup: '作息',
60
+ appearanceGroup: '外观',
61
+ appearanceDone: '完成',
62
+ advanced: '高级设置',
63
+ sessionHint: '仅用于记录配置读写操作,不是账本,不影响工资计算。',
64
+ session: '操作记录会话',
65
+ masked: '工资已隐藏',
66
+ reset: '复位位置',
67
+ synced: '已读取最新配置',
68
+ refresh: '读取最新配置',
69
+ busy: '保存中…',
70
+ storage: '浏览器禁止本地存储;位置与透明度本次有效。',
71
+ shiftDate: '班次',
72
+ opacity: '背景透明度',
73
+ opacityHint: '只影响背景,不影响文字。',
74
+ waterTitle: '喝水提醒',
75
+ waterEnable: '开启喝水提醒',
76
+ waterInterval: '自定义间隔(分钟)',
77
+ waterSave: '保存提醒设置',
78
+ waterEdit: '设置喝水提醒',
79
+ waterBack: '返回',
80
+ waterPresets: '常用间隔',
81
+ waterMinutes: '分钟',
82
+ waterEvery: '每',
83
+ waterEffective: '当前间隔',
84
+ waterManage: '数据管理',
85
+ waterSaveHint: '修改后点击保存,按新间隔重新计时。',
86
+ waterBrief: '按工作时间计时,午休暂停。关闭页面后不提醒。',
87
+ waterApply: '应用间隔',
88
+ waterInvalid: '请输入 5–240 的整数。',
89
+ waterNext: '下次喝水',
90
+ waterDue: '该喝水啦',
91
+ waterDrink: '喝了',
92
+ waterSnooze: '工作 10 分钟后',
93
+ waterPause: '本班暂停',
94
+ waterResume: '恢复本班提醒',
95
+ waterCount: '本班打卡',
96
+ waterManual: '喝了一杯',
97
+ waterQuick: '喝水快捷操作',
98
+ waterQuickClose: '收起喝水快捷操作',
99
+ waterRecorded: '这杯记下啦。',
100
+ waterRecordHint: '工作和午休期间可以打卡,关闭提醒也能记录。',
101
+ waterActionStale: '状态已变化,请再试一次。',
102
+ waterQuiet: '免打扰',
103
+ waterQuietAdjust: '调整时长',
104
+ waterQuiet30: '30 分钟',
105
+ waterQuiet60: '1 小时',
106
+ waterQuietEnd: '结束免打扰',
107
+ waterQuietStarted: '好,先安静一会儿,到时再提醒你。',
108
+ waterQuietEnded: '免打扰已结束。',
109
+ waterQuietHint: '按自然时间,到期自动恢复;已到期的提醒只提示一次。',
110
+ waterOff: '提醒已关闭',
111
+ waterWaiting: '等待工作时段',
112
+ waterBreak: '休息中,提醒计时暂停',
113
+ waterPaused: '本班已暂停',
114
+ waterFinished: '本班不再安排新提醒',
115
+ waterOther: '提醒已到期,等待可见标签页处理',
116
+ waterClear: '清除本班次数',
117
+ waterReset: '重置喝水提醒',
118
+ waterConfirm: '确认操作',
119
+ waterClearHint: '仅清零本班次数,保留下一次提醒和暂停状态。',
120
+ waterResetHint: '清除喝水提醒设置和记录,恢复默认关闭。',
121
+ waterLocal: '提醒设置和打卡仅保存在当前浏览器,不进入会话。与薪资配置分别保存。',
122
+ waterRules: '按工作分钟计时,午休冻结。开启、修改间隔或作息后重新计时;关闭页面后不提醒。',
123
+ waterUnavailable:
124
+ '喝水提醒存储不可用或数据无效,自动提醒已停止。可重试;重置将清除原喝水数据。',
125
+ waterRetry: '重试读取',
126
+ waterSetup: '请先保存薪资与作息。',
127
+ waterLoading: '正在读取喝水提醒…',
128
+ waterChanged: '提醒设置已在本浏览器生效',
129
+ waterStale: '作息已变化,请点击“读取最新配置”后继续提醒。',
23
130
  },
24
131
  en: {
25
- title:'Offwork Clock', working:'Until clock-out', before:'Until work starts', lunch:'On break', done:'Shift complete', rest:'Day off',
26
- earned:'Earned this shift · gross', daily:'Shift pay', hourly:'Paid-hour rate', progress:'Paid progress',
27
- settings:'Settings', collapse:'Minimize to strip', expand:'Expand', hide:'Hide salary', show:'Show salary',
28
- save:'Save', cancel:'Cancel', monthlySalary:'Monthly gross (CNY)', dailySalary:'Daily gross (CNY)',
29
- workdaysPerMonth:'Paid days / month', start:'Shift starts', end:'Shift ends', breakStart:'Break starts', breakEnd:'Break ends',
30
- timezone:'Time zone', weekdays:'Shift starting days', payMode:'Pay basis', monthly:'Monthly', dailyMode:'Daily',
31
- loading:'Loading settings…', retry:'Reload', noSession:'Open or create a session first.', error:'Operation failed. Please retry.',
32
- saved:'Settings saved', days:'SMTWTFS', privacy:'Settings stay local; results enter the selected session log.',
33
- drag:'Drag to move; arrow keys to adjust; Home resets', setup:'Set up your schedule', setupHint:'These are example values, not your income. Save to start.',
34
- breakEnabled:'Deduct unpaid break', noBreak:'no break', nextDay:'next day', preview:'Preview',
35
- salaryGroup:'Salary', scheduleGroup:'Schedule', appearanceGroup:'Appearance', advanced:'Advanced', sessionHint:'Logs configuration operations only; not a ledger and does not affect pay calculations.', session:'Action log session', masked:'Salary hidden', reset:'Reset position', synced:'Latest settings loaded',
36
- refresh:'Reload latest settings', busy:'Saving…', storage:'Local storage is blocked; position and opacity last only for this visit.',
37
- shiftDate:'Shift', opacity:'Surface opacity', opacityHint:'Affects the background only.',
132
+ ...COMPANION_MESSAGES.en,
133
+ ...PET_MESSAGES.en,
134
+ ...PET_INTERACTION_MESSAGES.en,
135
+ ...PET_SURFACE_MESSAGES.en,
136
+ title: 'Offwork Clock',
137
+ working: 'Until clock-out',
138
+ before: 'Until work starts',
139
+ lunch: 'On break',
140
+ done: 'Shift complete',
141
+ rest: 'Day off',
142
+ earned: 'Earned this shift · gross',
143
+ daily: 'Shift pay',
144
+ hourly: 'Paid-hour rate',
145
+ progress: 'Paid progress',
146
+ settings: 'Settings',
147
+ collapse: 'Minimize to strip',
148
+ expand: 'Expand',
149
+ hide: 'Hide salary',
150
+ show: 'Show salary',
151
+ save: 'Save',
152
+ cancel: 'Cancel',
153
+ monthlySalary: 'Monthly gross (CNY)',
154
+ dailySalary: 'Daily gross (CNY)',
155
+ workdaysPerMonth: 'Paid days / month',
156
+ start: 'Shift starts',
157
+ end: 'Shift ends',
158
+ breakStart: 'Break starts',
159
+ breakEnd: 'Break ends',
160
+ timezone: 'Time zone',
161
+ weekdays: 'Shift starting days',
162
+ payMode: 'Pay basis',
163
+ monthly: 'Monthly',
164
+ dailyMode: 'Daily',
165
+ loading: 'Loading settings…',
166
+ retry: 'Reload',
167
+ noSession: 'Open or create a session first.',
168
+ error: 'Operation failed. Please retry.',
169
+ saved: 'Settings saved',
170
+ days: 'SMTWTFS',
171
+ privacy: 'Settings are saved on the Harness host; results enter the selected session log.',
172
+ drag: 'Drag to move; arrow keys to adjust; Home resets',
173
+ setup: 'Set up your schedule',
174
+ setupHint: 'These are example values, not your income. Save to start.',
175
+ breakEnabled: 'Deduct unpaid break',
176
+ noBreak: 'no break',
177
+ nextDay: 'next day',
178
+ preview: 'Preview',
179
+ salaryGroup: 'Salary',
180
+ scheduleGroup: 'Schedule',
181
+ appearanceGroup: 'Appearance',
182
+ appearanceDone: 'Done',
183
+ advanced: 'Advanced',
184
+ sessionHint:
185
+ 'Logs configuration operations only; not a ledger and does not affect pay calculations.',
186
+ session: 'Action log session',
187
+ masked: 'Salary hidden',
188
+ reset: 'Reset position',
189
+ synced: 'Latest settings loaded',
190
+ refresh: 'Reload latest settings',
191
+ busy: 'Saving…',
192
+ storage: 'Local storage is blocked; position and opacity last only for this visit.',
193
+ shiftDate: 'Shift',
194
+ opacity: 'Surface opacity',
195
+ opacityHint: 'Affects the background only.',
196
+ waterTitle: 'Water reminder',
197
+ waterEnable: 'Enable water reminders',
198
+ waterInterval: 'Custom interval (minutes)',
199
+ waterSave: 'Save reminder settings',
200
+ waterEdit: 'Reminder settings',
201
+ waterBack: 'Back',
202
+ waterPresets: 'Quick intervals',
203
+ waterMinutes: 'min',
204
+ waterEvery: 'Every',
205
+ waterEffective: 'Current interval',
206
+ waterManage: 'Manage data',
207
+ waterSaveHint: 'Save changes to restart with the new interval.',
208
+ waterBrief: 'Counts working time and pauses on unpaid breaks. Keep the page open.',
209
+ waterApply: 'Apply interval',
210
+ waterInvalid: 'Enter a whole number from 5 to 240.',
211
+ waterNext: 'Next water break',
212
+ waterDue: 'Time for some water',
213
+ waterDrink: 'Drank water',
214
+ waterSnooze: 'In 10 working minutes',
215
+ waterPause: 'Pause this shift',
216
+ waterResume: 'Resume this shift',
217
+ waterCount: 'Drinks this shift',
218
+ waterManual: 'Had a glass',
219
+ waterQuick: 'Quick water actions',
220
+ waterQuickClose: 'Close quick water actions',
221
+ waterRecorded: 'Got it, your drink is recorded.',
222
+ waterRecordHint: 'Check in during work or breaks, even with reminders off.',
223
+ waterActionStale: 'The state changed. Please try again.',
224
+ waterQuiet: 'Quiet time',
225
+ waterQuietAdjust: 'Change duration',
226
+ waterQuiet30: '30 minutes',
227
+ waterQuiet60: '1 hour',
228
+ waterQuietEnd: 'End quiet time',
229
+ waterQuietStarted: 'Enjoy some quiet. I’ll remind you when it ends.',
230
+ waterQuietEnded: 'Quiet time has ended.',
231
+ waterQuietHint:
232
+ 'Uses clock time. Resumes automatically, with just one reminder if overdue.',
233
+ waterOff: 'Reminders off',
234
+ waterWaiting: 'Waiting for working hours',
235
+ waterBreak: 'On break; reminder timer paused',
236
+ waterPaused: 'Paused for this shift',
237
+ waterFinished: 'No more reminders this shift',
238
+ waterOther: 'Reminder due; waiting for a visible tab',
239
+ waterClear: 'Clear shift count',
240
+ waterReset: 'Reset water reminders',
241
+ waterConfirm: 'Confirm',
242
+ waterClearHint: 'Clear the drink count only. Keep the next reminder and pause state.',
243
+ waterResetHint: 'Delete water preferences and records, then turn reminders off.',
244
+ waterLocal:
245
+ 'Reminder preferences and check-ins stay in this browser, outside conversations. Saved separately from salary settings.',
246
+ waterRules:
247
+ 'Counts working minutes; unpaid breaks pause the timer. Enabling or changing interval or schedule restarts it. The page must stay open.',
248
+ waterUnavailable:
249
+ 'Water storage is unavailable or invalid; automatic reminders stopped. Retry, or reset to delete old water data.',
250
+ waterRetry: 'Retry loading',
251
+ waterSetup: 'Save your salary and schedule first.',
252
+ waterLoading: 'Loading water reminders…',
253
+ waterChanged: 'Reminder preferences saved in this browser',
254
+ waterStale: 'Schedule changed. Reload the latest settings to continue reminders.',
38
255
  },
39
256
  };
40
- const STYLE = `
257
+ const STYLE =
258
+ `
41
259
  .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}
42
260
  .owc-v2:active{cursor:grabbing}
43
261
  .owc-v2.owc-compact{touch-action:none;border-radius:18px}
262
+ .owc-v2.owc-compact.owc-quick{width:296px;touch-action:manipulation}
44
263
  .owc-v2:not(.owc-compact){width:296px}
45
264
  .owc-v2 :is(button,input,select,label,a){cursor:pointer;user-select:auto;-webkit-user-select:auto}
46
265
  .owc-v2 :is(input,select){user-select:text;-webkit-user-select:text}
47
266
  .owc-glass{position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:var(--owc-alpha,.82);background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 97%,var(--owc-accent));-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);border:1px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 18%,transparent);box-shadow:inset 0 1px 0 color-mix(in srgb,var(--dsw-alias-bg-base) 70%,transparent),0 8px 20px color-mix(in srgb,var(--dsw-alias-label-primary) 7%,transparent),0 14px 34px color-mix(in srgb,var(--owc-accent) 5%,transparent);transition:opacity .18s ease}
48
- .owc-content{position:relative;border-radius:inherit;max-height:calc(100dvh - 24px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
267
+ .owc-content{position:relative;border-radius:inherit;max-height:calc(100dvh - 24px);overflow:auto;overscroll-behavior:contain;scrollbar-width:none}
268
+ .owc-content::-webkit-scrollbar{display:none}
49
269
  .owc-v2 button{appearance:none;-webkit-appearance:none;border:0;margin:0;background:transparent;color:inherit;font:inherit;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:6px;min-height:28px;border-radius:10px;transition:background .15s ease,color .15s ease}
50
270
  .owc-v2 button:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 7%,transparent)}
51
271
  .owc-v2 button:focus-visible,.owc-v2 :is(input,select):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
@@ -121,91 +341,338 @@ window.__ModuleLoader__.load({
121
341
  @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.owc-glass{opacity:1}}
122
342
  @media(prefers-reduced-transparency:reduce),(prefers-contrast:more){.owc-glass{opacity:1;-webkit-backdrop-filter:none;backdrop-filter:none}}
123
343
  @media(prefers-reduced-motion:reduce){.owc-v2 button,.owc-glass,.owc-progress>div{transition:none}}
344
+ .owc-water{padding:10px 14px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent);font-size:12px;cursor:default}
345
+ .owc-water summary{cursor:pointer;font-weight:600;margin-bottom:8px}
346
+ .owc-water p{margin:6px 0;font-size:11px;line-height:1.6;overflow-wrap:anywhere}
347
+ .owc-water-actions{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px}
348
+ .owc-water-actions button{white-space:normal;text-align:left;font-size:11px}
349
+ .owc-water-due{background:color-mix(in srgb,var(--owc-accent) 9%,transparent);border-radius:0 0 18px 18px}
350
+ .owc-water-summary{font-size:11px!important;text-align:left;white-space:normal}
351
+ .owc-water input[type=number]{max-width:100px}
352
+ .owc-water .owc-check{margin-top:8px}
353
+ .owc-water-confirm{padding:8px;border:1px solid var(--owc-accent);border-radius:8px}
354
+ .owc-water-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
355
+ .owc-water-head strong{display:flex;align-items:center;gap:5px;font-size:12px}
356
+ .owc-water-quiet{margin:0!important;align-self:center;flex:1;min-width:90px}
357
+ .owc-water-quiet summary{cursor:pointer;font-size:11px;padding:6px}
358
+ .owc-water-quiet[open]{flex-basis:100%}
359
+ .owc-water-overview{padding:12px 17px}
360
+ .owc-water-overview .owc-water-status{font-size:12px;font-variant-numeric:tabular-nums;margin:7px 0}
361
+ .owc-water-meta{display:flex;justify-content:space-between;gap:8px;color:var(--dsw-alias-label-secondary);font-size:11px}
362
+ .owc-water-form{border-top:0;padding:4px 17px 16px}
363
+ .owc-water-toggle{display:flex;align-items:center;justify-content:space-between;padding:10px 0;font-weight:600}
364
+ .owc-water-toggle input{width:18px;height:18px;accent-color:var(--owc-accent)}
365
+ .owc-water-presets{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:8px 0 14px}
366
+ .owc-water-presets button{border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent);border-radius:10px;padding:8px 4px}
367
+ .owc-water-presets button[aria-pressed=true]{background:color-mix(in srgb,var(--owc-accent) 12%,transparent);color:var(--owc-accent);border-color:var(--owc-accent)}
368
+ .owc-water-form input[type=number]{max-width:none}
369
+ .owc-water-form fieldset{border:0;padding:0;margin:0;min-width:0}
370
+ .owc-water-form fieldset:disabled{opacity:.5}
371
+ .owc-water-form .owc-save{margin:12px 0}
372
+ .owc-water-form details{margin-top:14px;padding-top:10px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 10%,transparent)}
373
+ .owc-pill-salary{display:flex;align-items:center;gap:3px}
374
+ .owc-pill-water{font-size:11px!important;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
375
+ .owc-water-summary{justify-content:flex-start!important}
124
376
  @media(max-width:420px){.owc-pill{min-width:136px;padding:8px 8px 7px 12px}.owc-pill-status{display:none}.owc-pill-time{font-size:22px}.owc-main,.owc-form{padding-left:14px;padding-right:14px}.owc-time{font-size:31px}}
125
- `;
377
+ ` +
378
+ COMPANION_STYLE +
379
+ PET_STYLE +
380
+ PET_INTERACTION_STYLE +
381
+ PET_SURFACE_STYLE;
126
382
  const ICONS = {
127
- eye: [['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'}], ['circle', {cx:8, cy:8, r:2.05}]],
128
- eyeOff: [['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'}], ['circle', {cx:8, cy:8, r:2.05}], ['path', {d:'M3 3l10 10'}]],
129
- sliders: [['path', {d:'M2.2 4.6h3.4M9.2 4.6h4.6M2.2 11.4h5.4M11.4 11.4h2.4'}], ['circle', {cx:7.3, cy:4.6, r:1.55}], ['circle', {cx:9.4, cy:11.4, r:1.55}]],
130
- alarm: [['circle', {cx:8, cy:9, r:4.5}], ['path', {d:'M8 6.5V9l1.7 1M2 4l2-2M12 2l2 2M5 13l-1 2M11 13l1 2'}]],
131
- chevronUp: [['path', {d:'M3.7 9.8L8 5.5l4.3 4.3'}]],
132
- chevronDown: [['path', {d:'M3.7 6.2L8 10.5l4.3-4.3'}]],
133
- close: [['path', {d:'M4.3 4.3l7.4 7.4M11.7 4.3l-7.4 7.4'}]],
134
- refresh: [['path', {d:'M13.1 8a5.1 5.1 0 1 1-1.5-3.6'}], ['path', {d:'M13.1 3v3.2H9.9'}]],
383
+ water: [
384
+ ['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' }],
385
+ ],
386
+ eye: [
387
+ ['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' }],
388
+ ['circle', { cx: 8, cy: 8, r: 2.05 }],
389
+ ],
390
+ eyeOff: [
391
+ ['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' }],
392
+ ['circle', { cx: 8, cy: 8, r: 2.05 }],
393
+ ['path', { d: 'M3 3l10 10' }],
394
+ ],
395
+ sliders: [
396
+ ['path', { d: 'M2.2 4.6h3.4M9.2 4.6h4.6M2.2 11.4h5.4M11.4 11.4h2.4' }],
397
+ ['circle', { cx: 7.3, cy: 4.6, r: 1.55 }],
398
+ ['circle', { cx: 9.4, cy: 11.4, r: 1.55 }],
399
+ ],
400
+ alarm: [
401
+ ['circle', { cx: 8, cy: 9, r: 4.5 }],
402
+ ['path', { d: 'M8 6.5V9l1.7 1M2 4l2-2M12 2l2 2M5 13l-1 2M11 13l1 2' }],
403
+ ],
404
+ chevronUp: [['path', { d: 'M3.7 9.8L8 5.5l4.3 4.3' }]],
405
+ chevronDown: [['path', { d: 'M3.7 6.2L8 10.5l4.3-4.3' }]],
406
+ close: [['path', { d: 'M4.3 4.3l7.4 7.4M11.7 4.3l-7.4 7.4' }]],
407
+ refresh: [
408
+ ['path', { d: 'M13.1 8a5.1 5.1 0 1 1-1.5-3.6' }],
409
+ ['path', { d: 'M13.1 3v3.2H9.9' }],
410
+ ],
135
411
  };
136
- const iconSvg = (name, size = 15) => h('svg', {
137
- width: size, height: size, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor',
138
- strokeWidth: 1.55, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', focusable: 'false',
139
- }, ICONS[name].map(([tag, attrs], index) => h(tag, {key: index, ...attrs})));
412
+ const iconSvg = (name, size = 15) =>
413
+ h(
414
+ 'svg',
415
+ {
416
+ width: size,
417
+ height: size,
418
+ viewBox: '0 0 16 16',
419
+ fill: 'none',
420
+ stroke: 'currentColor',
421
+ strokeWidth: 1.55,
422
+ strokeLinecap: 'round',
423
+ strokeLinejoin: 'round',
424
+ 'aria-hidden': 'true',
425
+ focusable: 'false',
426
+ },
427
+ ICONS[name].map(([tag, attrs], index) => h(tag, { key: index, ...attrs })),
428
+ );
140
429
 
141
430
  return {
142
431
  inject: ['slots', 'locale', 'remote', 'remote.commands'],
143
432
  apply(ctx) {
144
- for (const [lang, dict] of Object.entries(messages)) ctx.effect(() => ctx.locale.register('offwork-clock', lang, dict));
145
- const viewKey = 'dsh.offwork-clock.view.v4';
433
+ for (const [lang, dict] of Object.entries(messages))
434
+ ctx.effect(() => ctx.locale.register('offwork-clock', lang, dict));
435
+ const viewKey = 'dsh.offwork-clock.view.v5';
436
+ const previousViewKey = 'dsh.offwork-clock.view.v4';
146
437
  const legacyKey = 'dsh.offwork-clock.view.v3';
147
438
 
148
439
  function Clock(props) {
149
- const locale = React.useSyncExternalStore(fn => ctx.locale.subscribe(fn), () => ctx.locale.getSnapshot());
440
+ const locale = React.useSyncExternalStore(
441
+ (fn) => ctx.locale.subscribe(fn),
442
+ () => ctx.locale.getSnapshot(),
443
+ );
150
444
  const t = ctx.locale.bind('offwork-clock');
151
- const sessions = props.useSessions(s => s);
445
+ const sessions = props.useSessions((s) => s);
152
446
  const sessionIds = Array.isArray(sessions?.ids) ? sessions.ids : [];
153
447
  const [sessionChoice, setSessionChoice] = React.useState('');
154
448
  const sessionId = sessionIds.includes(sessionChoice) ? sessionChoice : sessionIds[0];
155
449
  const [data, setData] = React.useState(null);
156
450
  const [draft, setDraft] = React.useState(null);
157
- const [editing, setEditing] = React.useState(false);
451
+ const [ui, dispatchUI] = React.useReducer(petUIReducer, initialPetUI);
452
+ const editing = ui.screen === 'schedule' || ui.screen === 'appearance';
453
+ const appearanceOnly = ui.screen === 'appearance';
454
+ const waterEditing = ui.screen === 'water';
455
+ const [appearanceDraft, setAppearanceDraft] = React.useState(null);
456
+ const appearanceBase = React.useRef(null);
457
+ const appearanceWasPet = React.useRef(false);
458
+ const salaryBase = React.useRef(null);
459
+ const menuBox = React.useRef(null);
460
+ const menuTrigger = React.useRef(null);
461
+ const [dragPosition, setDragPosition] = React.useState(null);
158
462
  const [busy, setBusy] = React.useState(false);
159
463
  const [error, setError] = React.useState('');
160
464
  const [notice, setNotice] = React.useState('');
161
465
  const [storageError, setStorageError] = React.useState(false);
162
466
  const [now, setNow] = React.useState(Date.now());
163
- const [view, setView] = React.useState(normalizeView(null));
467
+ const [savedView, setView] = React.useState(normalizeView(null));
468
+ const view =
469
+ appearanceOnly && appearanceDraft ? { ...savedView, ...appearanceDraft } : savedView;
164
470
  const [ready, setReady] = React.useState(false);
165
471
  const box = React.useRef(null);
472
+ const petPanel = React.useRef(null);
473
+ const cardContent = React.useRef(null);
474
+ const menuContent = React.useRef(null);
475
+ const toastContent = React.useRef(null);
476
+ const [toastHeight, setToastHeight] = React.useState(48);
477
+ const [cardMeasure, setCardMeasure] = React.useState({ key: '', height: 320 });
478
+ const [menuMeasure, setMenuMeasure] = React.useState({ width: 0, height: 260 });
479
+ const panelPlacement = React.useRef(null);
480
+ const petDragged = React.useRef(false);
481
+ const petOpen = ui.screen !== null;
482
+ const petDetails = ui.screen === 'income';
483
+ const [classicSize, setClassicSize] = React.useState({ width: 340, height: 240 });
484
+ const [viewport, setViewport] = React.useState(() => petViewport(window));
166
485
  const dragState = React.useRef(null);
167
486
  const lastPosition = React.useRef(null);
168
487
  const generation = React.useRef(0);
169
488
  const requestBusy = React.useRef(false);
170
489
  const focusReturn = React.useRef(null);
490
+ const [water, setWater] = React.useState(defaultHydration);
491
+ const [waterReady, setWaterReady] = React.useState(false);
492
+ const [waterError, setWaterError] = React.useState(false);
493
+ const [waterBusy, setWaterBusy] = React.useState(false);
494
+ const [waterVisible, setWaterVisible] = React.useState(!document.hidden);
495
+ const [waterInterval, setWaterInterval] = React.useState('60');
496
+ const [waterQuickOpen, setWaterQuickOpen] = React.useState(false);
497
+ const waterQuickTrigger = React.useRef(null);
498
+ const [waterEnabledDraft, setWaterEnabledDraft] = React.useState(false);
499
+ const waterPending = React.useRef(false);
500
+ const manualAttempt = React.useRef(null);
501
+ const waterPreferencesBase = React.useRef(null);
502
+ const editorWasOpen = React.useRef(false);
503
+ const previousPetMode = React.useRef(null);
504
+ const [waterInvalid, setWaterInvalid] = React.useState(false);
505
+ const [waterConfirm, setWaterConfirm] = React.useState(null);
506
+ const [waterNotice, setWaterNotice] = React.useState(false);
507
+ const reactionState = React.useRef(null);
508
+ const waterRuntime = React.useRef(null);
509
+ const waterOwner = React.useRef(null);
510
+ if (!waterOwner.current)
511
+ waterOwner.current =
512
+ window.crypto?.randomUUID?.() || String(Math.random()) + Date.now();
513
+ const waterInputs = React.useRef(null);
514
+ const waterScheduleChange = React.useRef(null);
515
+ waterInputs.current = {
516
+ settings: data?.configured && !error ? data.settings : null,
517
+ editing: editing || waterEditing,
518
+ };
171
519
 
172
520
  const settings = data?.settings;
173
521
  const workingDraft = draft || settings;
174
522
  const state = settings ? safeCalculate(settings) : null;
175
523
  const preview = workingDraft ? safeCalculate(workingDraft) : null;
176
524
  const safeState = state && !state.error ? state : null;
177
- const collapsed = !!safeState && !editing && view.mini && !error && !state?.error;
178
- const seconds = safeState ? (safeState.status === 'before' ? safeState.startRemaining : safeState.remaining) : NaN;
525
+ const petMode =
526
+ ready &&
527
+ data?.configured &&
528
+ !!safeState &&
529
+ (appearanceOnly ? appearanceWasPet.current : view.companion);
530
+ const stageModel = appearanceOnly ? savedView.petModel : view.petModel;
531
+ const petGeometry = petLayout(
532
+ dragPosition ?? view.petPosition,
533
+ viewport,
534
+ appearanceOnly ? savedView.petSize : view.petSize,
535
+ 420,
536
+ stageModel,
537
+ );
538
+ const surfaceScreen = ui.screen || 'reminder';
539
+ const surfaceWidth = petSurfaceWidth(surfaceScreen, viewport);
540
+ const measureKey = [surfaceScreen, surfaceWidth, locale.active].join(':');
541
+ const measured = cardMeasure.key === measureKey;
542
+ const naturalHeight = measured ? cardMeasure.height : 320;
543
+ const anchor = {
544
+ x: petGeometry.x,
545
+ y: petGeometry.y,
546
+ width: petGeometry.petWidth,
547
+ height: petGeometry.petHeight,
548
+ };
549
+ const placementKey = [
550
+ measureKey,
551
+ viewport.width,
552
+ viewport.height,
553
+ viewport.left,
554
+ viewport.top,
555
+ (dragPosition ?? savedView.petPosition)?.x,
556
+ (dragPosition ?? savedView.petPosition)?.y,
557
+ ].join(':');
558
+ const previousPlacement =
559
+ panelPlacement.current?.key === placementKey ? panelPlacement.current.panel : null;
560
+ const floatingPanel = placePetSurface(anchor, viewport, {
561
+ width: surfaceWidth,
562
+ height: naturalHeight,
563
+ previous: previousPlacement,
564
+ });
565
+ if (!petMode || !petOpen) panelPlacement.current = null;
566
+ else if (measured) panelPlacement.current = { key: placementKey, panel: floatingPanel };
567
+ const menuWidth = petSurfaceWidth('menu', viewport);
568
+ const menuLayout = placePetSurface(anchor, viewport, {
569
+ width: menuWidth,
570
+ height: menuMeasure.width === menuWidth ? menuMeasure.height : 260,
571
+ kind: 'menu',
572
+ point: ui.menu,
573
+ });
574
+ const alertRoom = Math.max(
575
+ anchor.x - viewport.left - 24,
576
+ viewport.left + viewport.width - anchor.x - anchor.width - 24,
577
+ );
578
+ const waterAlertLayout = placePetSurface(anchor, viewport, {
579
+ width: Math.min(140, Math.max(110, alertRoom)),
580
+ height: 34,
581
+ kind: 'menu',
582
+ obstacles: [...(petOpen ? [floatingPanel] : []), ...(ui.menu ? [menuLayout] : [])],
583
+ });
584
+ const toastLayout = placePetSurface(anchor, viewport, {
585
+ width: Math.min(224, viewport.width - 24),
586
+ height: toastHeight,
587
+ kind: 'menu',
588
+ });
589
+ const collapsed =
590
+ !petMode &&
591
+ !!safeState &&
592
+ !editing &&
593
+ !waterEditing &&
594
+ view.mini &&
595
+ !error &&
596
+ !state?.error;
597
+ const seconds = safeState
598
+ ? safeState.status === 'before'
599
+ ? safeState.startRemaining
600
+ : safeState.remaining
601
+ : NaN;
179
602
  const duration = formatDuration(seconds);
180
- const formatMoney = React.useMemo(() => new Intl.NumberFormat(locale.active, {
181
- style: 'currency', currency: 'CNY', minimumFractionDigits: 2, maximumFractionDigits: 2,
182
- }), [locale.active]);
183
- const money = value => view.masked ? '••••' : formatMoney.format(value);
603
+ const formatMoney = React.useMemo(
604
+ () =>
605
+ new Intl.NumberFormat(locale.active, {
606
+ style: 'currency',
607
+ currency: 'CNY',
608
+ minimumFractionDigits: 2,
609
+ maximumFractionDigits: 2,
610
+ }),
611
+ [locale.active],
612
+ );
613
+ const money = (value) => (view.masked ? '••••' : formatMoney.format(value));
184
614
  const moneyText = safeState ? money(safeState.earned) : '—';
185
- const cardStyle = Object.assign({ '--owc-alpha': String(view.opacity) },
186
- view.position ? { left: view.position.x, top: view.position.y, right: 'auto' } : {});
615
+ const classicPosition = clampPosition(
616
+ view.position ?? {
617
+ x: viewport.left + viewport.width - classicSize.width - 24,
618
+ y: viewport.top + 96,
619
+ },
620
+ viewport,
621
+ classicSize,
622
+ );
623
+ const cardStyle = {
624
+ '--owc-alpha': String(view.opacity),
625
+ '--owc-viewport-height': Math.max(1, viewport.height - 24) + 'px',
626
+ left: classicPosition.x,
627
+ top: classicPosition.y,
628
+ right: 'auto',
629
+ maxWidth: Math.max(1, viewport.width - 24),
630
+ maxHeight: Math.max(1, viewport.height - 24),
631
+ };
187
632
 
188
633
  function safeCalculate(value) {
189
- try { return calculate(value, now); }
190
- catch (e) { return { error: e instanceof Error ? e.message : t('error') }; }
634
+ try {
635
+ return calculate(value, now);
636
+ } catch (e) {
637
+ return { error: e instanceof Error ? e.message : t('error') };
638
+ }
639
+ }
640
+ function errorText(e) {
641
+ return e instanceof Error ? e.message : t('error');
642
+ }
643
+ function setPreference(patch) {
644
+ setView((current) => ({ ...current, ...patch }));
191
645
  }
192
- function errorText(e) { return e instanceof Error ? e.message : t('error'); }
193
- function setPreference(patch) { setView(current => ({ ...current, ...patch })); }
194
646
 
195
- const clamp = React.useCallback(position => clampPosition(position,
196
- { width: window.innerWidth, height: window.innerHeight },
197
- { width: box.current?.offsetWidth || 300, height: box.current?.offsetHeight || 200 }), []);
647
+ const clamp = React.useCallback(
648
+ (position) =>
649
+ clampPosition(position, petViewport(window), {
650
+ width: box.current?.offsetWidth || 300,
651
+ height: box.current?.offsetHeight || 200,
652
+ }),
653
+ [],
654
+ );
198
655
 
199
656
  React.useEffect(() => {
200
657
  try {
201
- const saved = localStorage.getItem(viewKey);
658
+ const saved = localStorage.getItem(viewKey) ?? localStorage.getItem(previousViewKey);
202
659
  const legacy = !saved ? JSON.parse(localStorage.getItem(legacyKey) || 'null') : null;
203
- setView(normalizeView(saved ? JSON.parse(saved) : { position: legacy?.position ?? null, masked: legacy?.masked ?? true }));
204
- } catch { setStorageError(true); }
660
+ setView(
661
+ normalizeView(
662
+ saved
663
+ ? JSON.parse(saved)
664
+ : { position: legacy?.position ?? null, masked: legacy?.masked ?? true },
665
+ ),
666
+ );
667
+ } catch {
668
+ setStorageError(true);
669
+ }
205
670
  setReady(true);
206
- const sync = e => {
671
+ const sync = (e) => {
207
672
  if (e.key !== viewKey) return;
208
- try { setView(normalizeView(JSON.parse(e.newValue || 'null'))); } catch {}
673
+ try {
674
+ setView(normalizeView(JSON.parse(e.newValue || 'null')));
675
+ } catch {}
209
676
  };
210
677
  window.addEventListener('storage', sync);
211
678
  return () => window.removeEventListener('storage', sync);
@@ -213,92 +680,518 @@ window.__ModuleLoader__.load({
213
680
 
214
681
  React.useEffect(() => {
215
682
  if (!ready) return;
216
- try { localStorage.setItem(viewKey, JSON.stringify(normalizeView(view))); }
217
- catch { setStorageError(true); }
218
- }, [ready, view]);
683
+ try {
684
+ localStorage.setItem(viewKey, JSON.stringify(normalizeView(savedView)));
685
+ } catch {
686
+ setStorageError(true);
687
+ }
688
+ }, [ready, savedView]);
689
+
690
+ React.useEffect(() => {
691
+ let active = true;
692
+ let running = 0;
693
+ let failed = false;
694
+ let store;
695
+ let channel;
696
+ try {
697
+ store = createHydrationStore(window.indexedDB);
698
+ } catch {
699
+ store = createHydrationStore(null);
700
+ }
701
+ try {
702
+ channel = new BroadcastChannel('dsh.offwork-clock.hydration.v1');
703
+ } catch {}
704
+ const contextAt = (time) => {
705
+ const current = waterInputs.current.settings;
706
+ if (!current) return null;
707
+ try {
708
+ return hydrationContext(current, calculate(current, time));
709
+ } catch {
710
+ return null;
711
+ }
712
+ };
713
+ const update = async (action = { type: 'tick' }, automatic = false) => {
714
+ if (!active || (automatic && (running || failed))) return;
715
+ running++;
716
+ try {
717
+ if (action.type === 'retry') {
718
+ store.close();
719
+ try {
720
+ store = createHydrationStore(window.indexedDB);
721
+ } catch {
722
+ store = createHydrationStore(null);
723
+ }
724
+ }
725
+ let changed = false;
726
+ let recorded = false;
727
+ let quietChanged = false;
728
+ let usedPermit = null;
729
+ const result = await store.update(
730
+ (current) => {
731
+ const time = Date.now();
732
+ const context = contextAt(time);
733
+ if (action.type === 'sync') return current;
734
+ const permit = waterScheduleChange.current;
735
+ const operation =
736
+ permit && context && permit.schedule === context.schedule
737
+ ? { ...action, baseSchedule: permit.baseSchedule }
738
+ : action;
739
+ let next = transitionHydration(
740
+ current,
741
+ context,
742
+ operation,
743
+ time,
744
+ waterOwner.current,
745
+ );
746
+ recorded =
747
+ ['drink', 'manual-drink'].includes(action.type) &&
748
+ next.shift?.count >
749
+ (current.shift?.id === next.shift?.id ? (current.shift?.count ?? 0) : 0);
750
+ quietChanged =
751
+ ['quiet', 'end-quiet'].includes(action.type) &&
752
+ next.shift?.quietUntil !== current.shift?.quietUntil;
753
+ if (automatic) {
754
+ const allow = !document.hidden && !waterInputs.current.editing;
755
+ next = transitionHydration(
756
+ next,
757
+ context,
758
+ { type: allow ? 'claim' : 'release' },
759
+ time,
760
+ waterOwner.current,
761
+ );
762
+ }
763
+ if (
764
+ permit &&
765
+ context &&
766
+ permit.schedule === context.schedule &&
767
+ next.shift?.schedule === context.schedule
768
+ )
769
+ usedPermit = permit;
770
+ changed = JSON.stringify(next) !== JSON.stringify(current);
771
+ return next;
772
+ },
773
+ { reset: action.type === 'reset' },
774
+ );
775
+ if (!active) return;
776
+ if (
777
+ action.type === 'reset' ||
778
+ (usedPermit && waterScheduleChange.current === usedPermit)
779
+ )
780
+ waterScheduleChange.current = null;
781
+ failed = false;
782
+ setWater(result);
783
+ setWaterReady(true);
784
+ setWaterError(false);
785
+ if (changed || action.type === 'reset') {
786
+ try {
787
+ channel?.postMessage('changed');
788
+ } catch {}
789
+ }
790
+ return { state: result, recorded, quietChanged };
791
+ } catch (waterFailure) {
792
+ console.warn(
793
+ 'Water reminder storage failed:',
794
+ waterFailure?.name,
795
+ waterFailure?.message,
796
+ );
797
+ if (active) {
798
+ failed = true;
799
+ setWaterError(true);
800
+ setWaterReady(true);
801
+ }
802
+ } finally {
803
+ running--;
804
+ }
805
+ };
806
+ const tick = () => {
807
+ void update({ type: 'tick' }, true);
808
+ };
809
+ const visibility = () => {
810
+ setWaterVisible(!document.hidden);
811
+ setNow(Date.now());
812
+ if (document.hidden) void update({ type: 'release' });
813
+ else tick();
814
+ };
815
+ const pageHide = () => {
816
+ setWaterVisible(false);
817
+ void update({ type: 'release' });
818
+ };
819
+ waterRuntime.current = { update, tick };
820
+ if (channel)
821
+ channel.onmessage = () => {
822
+ void update({ type: 'sync' });
823
+ };
824
+ const timer = setInterval(tick, 2000);
825
+ document.addEventListener('visibilitychange', visibility);
826
+ window.addEventListener('pageshow', visibility);
827
+ window.addEventListener('pagehide', pageHide);
828
+ window.addEventListener('focus', tick);
829
+ tick();
830
+ return () => {
831
+ active = false;
832
+ waterRuntime.current = null;
833
+ clearInterval(timer);
834
+ document.removeEventListener('visibilitychange', visibility);
835
+ window.removeEventListener('pageshow', visibility);
836
+ window.removeEventListener('pagehide', pageHide);
837
+ window.removeEventListener('focus', tick);
838
+ channel?.close();
839
+ void store
840
+ .update((current) => {
841
+ if (current.shift?.claim?.owner === waterOwner.current)
842
+ current.shift.claim = null;
843
+ return current;
844
+ })
845
+ .catch(() => {})
846
+ .finally(() => store.close());
847
+ };
848
+ }, []);
849
+
850
+ React.useEffect(() => {
851
+ waterRuntime.current?.tick();
852
+ }, [settings, editing, waterEditing, error]);
853
+
854
+ React.useEffect(() => {
855
+ if (!waterNotice) return;
856
+ const timer = setTimeout(() => setWaterNotice(false), 5000);
857
+ return () => clearTimeout(timer);
858
+ }, [waterNotice]);
859
+
860
+ React.useEffect(() => {
861
+ if (!waterQuickOpen || !collapsed) return;
862
+ const closeOutside = (event) => {
863
+ if (!box.current?.contains(event.target)) setWaterQuickOpen(false);
864
+ };
865
+ document.addEventListener('pointerdown', closeOutside);
866
+ return () => document.removeEventListener('pointerdown', closeOutside);
867
+ }, [waterQuickOpen, collapsed]);
868
+
869
+ function toggleWaterQuick(event) {
870
+ waterQuickTrigger.current = event.currentTarget;
871
+ setWaterQuickOpen((open) => !open);
872
+ }
873
+
874
+ function closeWaterQuick() {
875
+ setWaterQuickOpen(false);
876
+ (
877
+ (waterQuickTrigger.current?.isConnected ? waterQuickTrigger.current : null) ||
878
+ box.current?.querySelector('.owc-pill-water') ||
879
+ box.current?.querySelector('.owc-companion-trigger') ||
880
+ box.current?.querySelector('.owc-grip')
881
+ )?.focus();
882
+ }
883
+
884
+ function openWaterEditor(event) {
885
+ navigateScreen('water', event);
886
+ }
887
+
888
+ function closeWaterEditor() {
889
+ if (waterPending.current) return;
890
+ performNavigation(ui.back);
891
+ }
892
+
893
+ async function saveWaterSettings(event) {
894
+ event.preventDefault();
895
+ const interval = Number(waterInterval);
896
+ if (!Number.isInteger(interval) || interval < 5 || interval > 240) {
897
+ setWaterInvalid(true);
898
+ return;
899
+ }
900
+ const result = await waterAction({
901
+ type: 'preferences',
902
+ ...(waterEnabledDraft !== waterPreferencesBase.current?.enabled
903
+ ? { enabled: waterEnabledDraft }
904
+ : {}),
905
+ ...(interval !== waterPreferencesBase.current?.interval ? { interval } : {}),
906
+ });
907
+ if (result) performNavigation(ui.back);
908
+ return result;
909
+ }
910
+
911
+ async function waterAction(action) {
912
+ if (waterPending.current) return;
913
+ waterPending.current = true;
914
+ setWaterBusy(true);
915
+ setWaterNotice(false);
916
+ const actionFocus = document.activeElement;
917
+ const hadFocus = box.current?.querySelector('.owc-water-due')?.contains(actionFocus);
918
+ try {
919
+ const outcome = await waterRuntime.current?.update(action);
920
+ const result = outcome?.state;
921
+ if (result) {
922
+ setWaterConfirm(null);
923
+ setNow(Date.now());
924
+ if (action.type === 'preferences')
925
+ setWaterNotice({ key: 'waterChanged', kind: 'notice' });
926
+ if (action.type === 'drink' && outcome.recorded)
927
+ setWaterNotice({ key: 'waterRecorded', kind: 'drink', token: result.revision });
928
+ if (action.type === 'manual-drink') {
929
+ setWaterNotice({
930
+ key: result.shift?.recentActions.includes(action.actionId)
931
+ ? 'waterRecorded'
932
+ : 'waterActionStale',
933
+ kind: outcome.recorded ? 'drink' : 'notice',
934
+ token: result.revision,
935
+ });
936
+ manualAttempt.current = null;
937
+ }
938
+ if (action.type === 'quiet')
939
+ setWaterNotice({
940
+ key:
941
+ outcome.quietChanged && result.shift?.quietUntil > Date.now()
942
+ ? 'waterQuietStarted'
943
+ : 'waterActionStale',
944
+ kind: 'notice',
945
+ });
946
+ if (action.type === 'end-quiet')
947
+ setWaterNotice({
948
+ key: outcome.quietChanged ? 'waterQuietEnded' : 'waterActionStale',
949
+ kind: 'notice',
950
+ });
951
+ if (action.type === 'reset') {
952
+ manualAttempt.current = null;
953
+ setWaterInterval('60');
954
+ setWaterEnabledDraft(false);
955
+ setWaterInvalid(false);
956
+ }
957
+ if (
958
+ hadFocus ||
959
+ ['manual-drink', 'end-quiet', 'clear-count', 'reset'].includes(action.type)
960
+ ) {
961
+ requestAnimationFrame(() => {
962
+ const active = document.activeElement;
963
+ if (active !== actionFocus && active?.tagName !== 'BODY') return;
964
+ const target = hadFocus
965
+ ? petMode
966
+ ? '.owc-pet-handle'
967
+ : '.owc-grip,.owc-title'
968
+ : action.type === 'manual-drink'
969
+ ? petMode
970
+ ? '.owc-pet-water-row .owc-primary'
971
+ : '.owc-water-actions .owc-primary'
972
+ : action.type === 'end-quiet'
973
+ ? petMode
974
+ ? '.owc-pet-quiet-row button'
975
+ : '.owc-water-quiet summary'
976
+ : '.owc-water-form details > summary';
977
+ box.current?.querySelector(target)?.focus();
978
+ });
979
+ }
980
+ waterRuntime.current?.tick();
981
+ }
982
+ return result;
983
+ } finally {
984
+ waterPending.current = false;
985
+ setWaterBusy(false);
986
+ }
987
+ }
219
988
 
220
989
  React.useEffect(() => {
221
990
  if (sessionId) reload();
222
991
  else setError(t('noSession'));
223
- return () => { generation.current++; };
992
+ return () => {
993
+ generation.current++;
994
+ };
224
995
  }, [sessionId]);
225
996
 
226
997
  React.useEffect(() => {
227
998
  let timer;
228
- const tick = () => { setNow(Date.now()); timer = setTimeout(tick, 1000 - Date.now() % 1000); };
229
- const visibility = () => { clearTimeout(timer); setNow(Date.now()); if (!document.hidden) tick(); };
999
+ const tick = () => {
1000
+ setNow(Date.now());
1001
+ timer = setTimeout(tick, 1000 - (Date.now() % 1000));
1002
+ };
1003
+ const visibility = () => {
1004
+ clearTimeout(timer);
1005
+ setNow(Date.now());
1006
+ if (!document.hidden) tick();
1007
+ };
230
1008
  if (!document.hidden) tick();
231
1009
  document.addEventListener('visibilitychange', visibility);
232
- return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', visibility); };
1010
+ return () => {
1011
+ clearTimeout(timer);
1012
+ document.removeEventListener('visibilitychange', visibility);
1013
+ };
233
1014
  }, []);
234
1015
 
235
- React.useEffect(() => {
236
- const resize = () => setView(current => current.position ? { ...current, position: clamp(current.position) } : current);
1016
+ React.useLayoutEffect(() => {
1017
+ const resize = () => {
1018
+ const rect = box.current?.getBoundingClientRect();
1019
+ const nextViewport = petViewport(window);
1020
+ setViewport((current) =>
1021
+ Object.keys(nextViewport).every((key) => current[key] === nextViewport[key])
1022
+ ? current
1023
+ : nextViewport,
1024
+ );
1025
+ if (!rect || petMode) return;
1026
+ const width = box.current.offsetWidth,
1027
+ height = box.current.offsetHeight;
1028
+ setClassicSize((current) =>
1029
+ current.width === width && current.height === height ? current : { width, height },
1030
+ );
1031
+ };
237
1032
  const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
238
1033
  if (box.current) ro?.observe(box.current);
1034
+ resize();
239
1035
  window.addEventListener('resize', resize);
240
- return () => { ro?.disconnect(); window.removeEventListener('resize', resize); };
241
- }, [clamp]);
1036
+ window.visualViewport?.addEventListener('resize', resize);
1037
+ window.visualViewport?.addEventListener('scroll', resize);
1038
+ return () => {
1039
+ ro?.disconnect();
1040
+ window.removeEventListener('resize', resize);
1041
+ window.visualViewport?.removeEventListener('resize', resize);
1042
+ window.visualViewport?.removeEventListener('scroll', resize);
1043
+ };
1044
+ }, [clamp, petMode]);
242
1045
 
243
1046
  React.useEffect(() => {
244
- if (!editing) { focusReturn.current?.focus?.(); return; }
245
- box.current?.querySelector('form input,form select')?.focus?.();
246
- }, [editing]);
1047
+ if (ui.pending) {
1048
+ box.current?.querySelector('[data-keep-editing]')?.focus();
1049
+ return;
1050
+ }
1051
+ const wasOpen = editorWasOpen.current;
1052
+ const modeChanged = previousPetMode.current !== petMode;
1053
+ previousPetMode.current = petMode;
1054
+ editorWasOpen.current = editing || waterEditing;
1055
+ if (editing && modeChanged) {
1056
+ box.current?.querySelector('input[name=companion]')?.focus();
1057
+ return;
1058
+ }
1059
+ if (!editing && !waterEditing) {
1060
+ if (!wasOpen) return;
1061
+ if (focusReturn.current?.isConnected) focusReturn.current.focus?.();
1062
+ else if (petMode) box.current?.querySelector('.owc-pet-handle')?.focus?.();
1063
+ else if (waterQuickOpen)
1064
+ box.current?.querySelector('[data-water-quick] .owc-text')?.focus?.();
1065
+ else box.current?.querySelector('.owc-grip,.owc-title')?.focus?.();
1066
+ focusReturn.current = null;
1067
+ return;
1068
+ }
1069
+ box.current?.querySelector('form input,form select,.owc-appearance input')?.focus?.();
1070
+ }, [editing, waterEditing, petMode, ui.screen, ui.pending]);
247
1071
 
248
- React.useEffect(() => () => { generation.current++; dragState.current = null; }, []);
1072
+ React.useEffect(
1073
+ () => () => {
1074
+ generation.current++;
1075
+ dragState.current = null;
1076
+ },
1077
+ [],
1078
+ );
249
1079
 
250
1080
  async function request(action, patch) {
251
1081
  if (!sessionId) throw new Error(t('noSession'));
252
- const rpc = await ctx.remote.commands.execute(sessionId, '/offwork-clock ' + action + (patch ? ' ' + JSON.stringify(patch) : ''), []);
1082
+ const rpc = await ctx.remote.commands.execute(
1083
+ sessionId,
1084
+ '/offwork-clock ' + action + (patch ? ' ' + JSON.stringify(patch) : ''),
1085
+ [],
1086
+ );
253
1087
  return decodeCommand(rpc);
254
1088
  }
255
1089
 
256
1090
  async function reload(explicit = false) {
257
1091
  if (requestBusy.current) return;
258
1092
  const token = ++generation.current;
259
- setBusy(true); setError('');
1093
+ setBusy(true);
1094
+ setError('');
260
1095
  try {
1096
+ const baseSchedule = (await waterRuntime.current?.update({ type: 'sync' }))?.state
1097
+ ?.shift?.schedule;
1098
+ if (token !== generation.current) return;
261
1099
  const next = await request('get');
262
1100
  if (token !== generation.current) return;
1101
+ if (next.configured)
1102
+ waterScheduleChange.current = {
1103
+ baseSchedule,
1104
+ schedule: hydrationContext(next.settings, calculate(next.settings, Date.now()))
1105
+ .schedule,
1106
+ };
263
1107
  setData(next);
264
- if (!next.configured) { setDraft({ ...next.settings }); setEditing(true); }
1108
+ if (!next.configured) {
1109
+ setDraft({ ...next.settings });
1110
+ dispatchUI({ type: 'navigate', screen: 'schedule', discard: true });
1111
+ salaryBase.current = next.settings;
1112
+ }
265
1113
  if (explicit) setNotice(t('synced'));
266
- } catch (e) { if (token === generation.current) setError(errorText(e)); }
267
- finally { if (token === generation.current) setBusy(false); }
1114
+ } catch (e) {
1115
+ if (token === generation.current) setError(errorText(e));
1116
+ } finally {
1117
+ if (token === generation.current) setBusy(false);
1118
+ }
268
1119
  }
269
1120
 
270
1121
  async function save(event) {
271
1122
  event.preventDefault();
272
1123
  if (requestBusy.current) return;
273
- requestBusy.current = true; setBusy(true); setError('');
1124
+ requestBusy.current = true;
1125
+ setBusy(true);
1126
+ setError('');
274
1127
  const token = ++generation.current;
275
1128
  try {
276
- const next = await request('set', changedSettings(settings, draft));
1129
+ const baseSchedule = (await waterRuntime.current?.update({ type: 'sync' }))?.state
1130
+ ?.shift?.schedule;
1131
+ if (token !== generation.current) return;
1132
+ const next = await request(
1133
+ 'set',
1134
+ changedSettings(salaryBase.current ?? settings, draft),
1135
+ );
277
1136
  if (token === generation.current) {
278
- setData(next); setEditing(false); setDraft(null); setNotice(t('saved'));
1137
+ waterScheduleChange.current = {
1138
+ baseSchedule,
1139
+ schedule: hydrationContext(next.settings, calculate(next.settings, Date.now()))
1140
+ .schedule,
1141
+ };
1142
+ setData(next);
1143
+ dispatchUI({ type: 'navigate', screen: ui.back, discard: true });
1144
+ setDraft(null);
1145
+ setNotice(t('saved'));
1146
+ return next;
279
1147
  }
280
- } catch (e) { if (token === generation.current) setError(errorText(e)); }
281
- finally { requestBusy.current = false; if (token === generation.current) setBusy(false); }
1148
+ } catch (e) {
1149
+ if (token === generation.current) setError(errorText(e));
1150
+ } finally {
1151
+ requestBusy.current = false;
1152
+ if (token === generation.current) setBusy(false);
1153
+ }
282
1154
  }
283
1155
 
284
1156
  function commitPosition(position) {
285
1157
  const next = clamp(position);
286
- setPreference({ position: next });
1158
+ setPreference(petMode ? { petPosition: next } : { position: next });
287
1159
  lastPosition.current = next;
288
1160
  }
289
1161
  function dragDown(e) {
290
1162
  if (e.button !== 0) return;
291
- if (e.target.closest?.('button,input,select,label,a,textarea,summary,[contenteditable]')) return;
1163
+ petDragged.current = false;
1164
+ if (
1165
+ petMode
1166
+ ? !e.target.closest?.('.owc-pet-handle')
1167
+ : e.target.closest?.(
1168
+ 'button,input,select,label,a,textarea,summary,[contenteditable]',
1169
+ )
1170
+ )
1171
+ return;
1172
+ const capture = petMode ? e.target.closest('.owc-pet-handle') : e.currentTarget;
1173
+ if (petMode && ui.menu) dispatchUI({ type: 'dismiss-menu' });
292
1174
  const rect = box.current.getBoundingClientRect();
293
- dragState.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x0: e.clientX, y0: e.clientY, moved: false };
294
- try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch {}
1175
+ dragState.current = {
1176
+ dx: e.clientX - rect.left,
1177
+ dy: e.clientY - rect.top,
1178
+ x0: e.clientX,
1179
+ y0: e.clientY,
1180
+ moved: false,
1181
+ capture,
1182
+ };
1183
+ try {
1184
+ capture.setPointerCapture?.(e.pointerId);
1185
+ } catch {}
295
1186
  }
296
1187
  function dragMove(e) {
297
1188
  const d = dragState.current;
298
1189
  if (!d) return;
299
- if (!d.moved && Math.abs(e.clientX - d.x0) + Math.abs(e.clientY - d.y0) < 4) return;
1190
+ if (!d.moved && Math.hypot(e.clientX - d.x0, e.clientY - d.y0) < 6) return;
300
1191
  d.moved = true;
1192
+ petDragged.current = true;
301
1193
  const next = clamp({ x: e.clientX - d.dx, y: e.clientY - d.dy });
1194
+ if (petMode) setDragPosition(next);
302
1195
  lastPosition.current = next;
303
1196
  if (box.current) {
304
1197
  box.current.style.left = next.x + 'px';
@@ -310,11 +1203,21 @@ window.__ModuleLoader__.load({
310
1203
  const d = dragState.current;
311
1204
  if (!d) return;
312
1205
  dragState.current = null;
1206
+ if (e.type === 'pointercancel' || e.type === 'lostpointercapture')
1207
+ petDragged.current = false;
313
1208
  if (d.moved && lastPosition.current) commitPosition(lastPosition.current);
314
- try { if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
1209
+ setDragPosition(null);
1210
+ try {
1211
+ if (d.capture?.hasPointerCapture?.(e.pointerId))
1212
+ d.capture.releasePointerCapture(e.pointerId);
1213
+ } catch {}
315
1214
  }
316
1215
  function gripKeys(e) {
317
- if (e.key === 'Home') { e.preventDefault(); setPreference({ position: null }); return; }
1216
+ if (e.key === 'Home') {
1217
+ e.preventDefault();
1218
+ setPreference(petMode ? { petPosition: null } : { position: null });
1219
+ return;
1220
+ }
318
1221
  if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
319
1222
  e.preventDefault();
320
1223
  const rect = box.current.getBoundingClientRect();
@@ -324,210 +1227,2122 @@ window.__ModuleLoader__.load({
324
1227
  });
325
1228
  }
326
1229
 
327
- function openEditor(e) {
328
- focusReturn.current = e.currentTarget;
329
- setDraft({ ...settings });
330
- setEditing(true);
331
- setError(''); setNotice('');
1230
+ function openAppearanceContext(event) {
1231
+ if (
1232
+ !box.current?.contains(event.target) ||
1233
+ event.target.closest?.(
1234
+ 'textarea,select,input:not([type=checkbox]):not([type=radio]):not([type=range]),[contenteditable=true],[contenteditable=""]',
1235
+ )
1236
+ )
1237
+ return false;
1238
+ event.preventDefault();
1239
+ event.stopPropagation();
1240
+ petDragged.current = false;
1241
+ if (ui.pending) return true;
1242
+ if (!petMode) {
1243
+ if (!busy && !waterBusy) navigateScreen('appearance', event);
1244
+ return true;
1245
+ }
1246
+ const trigger =
1247
+ event.target.closest('button,.owc-title,[data-pet-page-title]') ||
1248
+ event.target
1249
+ .closest('.owc-pet-panel')
1250
+ ?.querySelector('[data-pet-page-title],.owc-head button') ||
1251
+ box.current.querySelector('.owc-pet-handle');
1252
+ menuTrigger.current = menuBox.current?.contains(trigger)
1253
+ ? menuTrigger.current?.isConnected
1254
+ ? menuTrigger.current
1255
+ : box.current?.querySelector('.owc-pet-handle')
1256
+ : trigger;
1257
+ const rect = trigger?.getBoundingClientRect();
1258
+ const point =
1259
+ event.type === 'keydown'
1260
+ ? { x: rect?.right ?? petGeometry.x, y: rect?.top ?? petGeometry.y }
1261
+ : { x: event.clientX, y: event.clientY };
1262
+ dispatchUI({ type: 'menu', position: point });
1263
+ return true;
1264
+ }
1265
+ function appearanceShortcut(event) {
1266
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))
1267
+ return openAppearanceContext(event);
1268
+ return false;
1269
+ }
1270
+ function performNavigation(screen, root = false) {
1271
+ if (root) panelPlacement.current = null;
1272
+ if (screen !== ui.screen) {
1273
+ if (screen === 'water') {
1274
+ waterPreferencesBase.current = { enabled: water.enabled, interval: water.interval };
1275
+ setWaterInterval(String(water.interval));
1276
+ setWaterEnabledDraft(water.enabled);
1277
+ setWaterInvalid(false);
1278
+ setWaterConfirm(null);
1279
+ }
1280
+ if (screen === 'schedule') {
1281
+ salaryBase.current = settings;
1282
+ setDraft({ ...settings });
1283
+ setError('');
1284
+ }
1285
+ if (screen === 'appearance') {
1286
+ appearanceBase.current = appearanceValues(savedView);
1287
+ appearanceWasPet.current = !!petMode;
1288
+ setAppearanceDraft(appearanceValues(savedView));
1289
+ }
1290
+ if (ui.screen === 'schedule') {
1291
+ setDraft(null);
1292
+ setError('');
1293
+ }
1294
+ if (ui.screen === 'appearance') setAppearanceDraft(null);
1295
+ }
1296
+ dispatchUI({ type: 'navigate', screen, discard: true, root });
1297
+ setWaterConfirm(null);
1298
+ }
1299
+ function navigateScreen(screen, event) {
1300
+ if (!data?.configured && screen !== 'schedule') return;
1301
+ if (ui.pending || busy || waterBusy || requestBusy.current || waterPending.current)
1302
+ return;
1303
+ const root = event?.currentTarget?.getAttribute?.('role') === 'menuitem';
1304
+ if (event?.currentTarget) focusReturn.current = event.currentTarget;
1305
+ if (screen !== ui.screen && draftChanged()) {
1306
+ dispatchUI({ type: 'navigate', screen, dirty: true, root });
1307
+ return;
1308
+ }
1309
+ performNavigation(screen, root);
1310
+ }
1311
+ function draftChanged() {
1312
+ if (waterEditing && waterPreferencesBase.current)
1313
+ return (
1314
+ waterEnabledDraft !== waterPreferencesBase.current.enabled ||
1315
+ Number(waterInterval) !== waterPreferencesBase.current.interval
1316
+ );
1317
+ if (ui.screen === 'schedule' && draft && salaryBase.current)
1318
+ return Object.keys(changedSettings(salaryBase.current, draft)).length > 0;
1319
+ if (appearanceOnly && appearanceDraft && appearanceBase.current)
1320
+ return (
1321
+ Object.keys(changedSettings(appearanceBase.current, appearanceDraft)).length > 0
1322
+ );
1323
+ return false;
1324
+ }
1325
+ function applyAppearance() {
1326
+ panelPlacement.current = null;
1327
+ if (appearanceDraft)
1328
+ setPreference(changedSettings(appearanceBase.current, appearanceDraft));
1329
+ performNavigation(ui.back);
1330
+ return true;
1331
+ }
1332
+ async function saveAndContinue() {
1333
+ if (!ui.pending || busy || waterBusy) return;
1334
+ const destination = ui.pending.screen;
1335
+ const result = waterEditing
1336
+ ? await saveWaterSettings({ preventDefault() {} })
1337
+ : appearanceOnly
1338
+ ? applyAppearance()
1339
+ : await save({ preventDefault() {} });
1340
+ if (result) performNavigation(destination, ui.pending.root);
1341
+ else dispatchUI({ type: 'stay' });
1342
+ }
1343
+ function updateAppearance(patch) {
1344
+ setAppearanceDraft((current) => ({ ...current, ...patch }));
1345
+ }
1346
+ function openAppearance(event) {
1347
+ navigateScreen('appearance', event);
1348
+ }
1349
+ function openEditor(event) {
1350
+ navigateScreen('schedule', event);
332
1351
  }
333
1352
  function closeEditor() {
334
- if (!data?.configured) return;
335
- setEditing(false); setDraft(null); setError('');
1353
+ if ((!data?.configured && !appearanceOnly) || busy) return;
1354
+ performNavigation(ui.back);
1355
+ }
1356
+ function update(key, value) {
1357
+ setDraft((current) => ({ ...current, [key]: value }));
336
1358
  }
337
- function update(key, value) { setDraft(current => ({ ...current, [key]: value })); }
338
1359
  function toggleBreak(e) {
339
- const point = text => +text.slice(0, 2) * 60 + +text.slice(3);
340
- const label = minutes => String(Math.floor(minutes / 60)).padStart(2, '0') + ':' + String(minutes % 60).padStart(2, '0');
341
- setDraft(current => {
342
- if (!e.target.checked) return { ...current, breakStart: current.start, breakEnd: current.start };
1360
+ const point = (text) => +text.slice(0, 2) * 60 + +text.slice(3);
1361
+ const label = (minutes) =>
1362
+ String(Math.floor(minutes / 60)).padStart(2, '0') +
1363
+ ':' +
1364
+ String(minutes % 60).padStart(2, '0');
1365
+ setDraft((current) => {
1366
+ if (!e.target.checked)
1367
+ return { ...current, breakStart: current.start, breakEnd: current.start };
343
1368
  const start = point(current.start);
344
1369
  const span = (point(current.end) - start + 1440) % 1440;
345
- return { ...current, breakStart: current.start, breakEnd: label((start + Math.max(1, Math.min(60, Math.floor(span / 2)))) % 1440) };
1370
+ return {
1371
+ ...current,
1372
+ breakStart: current.start,
1373
+ breakEnd: label((start + Math.max(1, Math.min(60, Math.floor(span / 2)))) % 1440),
1374
+ };
346
1375
  });
347
1376
  }
348
1377
 
349
- const label = (text, control) => h('label', { className: 'owc-field' }, h('span', null, text), control);
350
- const numberInput = key => h('input', {
351
- name: key, type: 'number', step: 'any', min: key === 'workdaysPerMonth' ? '0.01' : '0',
352
- value: workingDraft?.[key] ?? '', disabled: busy,
353
- onChange: e => update(key, e.target.value === '' ? '' : e.target.valueAsNumber),
354
- });
355
- const timeInput = key => h('input', {
356
- name: key, type: 'time', required: true, value: workingDraft?.[key] ?? '', disabled: busy,
357
- onChange: e => update(key, e.target.value),
1378
+ const label = (text, control) =>
1379
+ h('label', { className: 'owc-field' }, h('span', null, text), control);
1380
+ const numberInput = (key) =>
1381
+ h('input', {
1382
+ name: key,
1383
+ type: 'number',
1384
+ step: 'any',
1385
+ min: key === 'workdaysPerMonth' ? '0.01' : '0',
1386
+ value: workingDraft?.[key] ?? '',
1387
+ disabled: busy,
1388
+ onChange: (e) => update(key, e.target.value === '' ? '' : e.target.valueAsNumber),
1389
+ });
1390
+ const timeInput = (key) =>
1391
+ h('input', {
1392
+ name: key,
1393
+ type: 'time',
1394
+ required: true,
1395
+ value: workingDraft?.[key] ?? '',
1396
+ disabled: busy,
1397
+ onChange: (e) => update(key, e.target.value),
1398
+ });
1399
+ const sessionSelect = () =>
1400
+ h(
1401
+ 'select',
1402
+ {
1403
+ value: sessionId || '',
1404
+ disabled: busy,
1405
+ onChange: (e) => setSessionChoice(e.target.value),
1406
+ },
1407
+ sessionIds.length
1408
+ ? sessionIds.map((id) =>
1409
+ h(
1410
+ 'option',
1411
+ { key: id, value: id },
1412
+ sessions?.byId?.[id]?.title || String(id).slice(0, 12),
1413
+ ),
1414
+ )
1415
+ : h('option', { value: '' }, t('noSession')),
1416
+ );
1417
+ const salaryToggle = () =>
1418
+ h(
1419
+ 'button',
1420
+ {
1421
+ type: 'button',
1422
+ className: 'owc-text',
1423
+ 'aria-pressed': view.masked,
1424
+ title: t(view.masked ? 'show' : 'hide'),
1425
+ onClick: () => setPreference({ masked: !view.masked }),
1426
+ },
1427
+ iconSvg(view.masked ? 'eyeOff' : 'eye', 13),
1428
+ t(view.masked ? 'show' : 'hide'),
1429
+ );
1430
+
1431
+ const waterContext =
1432
+ data?.configured && safeState && !error ? hydrationContext(settings, safeState) : null;
1433
+ const waterState = hydrationView(water, waterContext, now);
1434
+ const waterClaim = water.shift?.claim;
1435
+ const waterPrompt =
1436
+ waterReady &&
1437
+ !waterError &&
1438
+ !editing &&
1439
+ !waterEditing &&
1440
+ waterVisible &&
1441
+ !document.hidden &&
1442
+ waterState.phase === 'due' &&
1443
+ waterClaim?.owner === waterOwner.current &&
1444
+ waterClaim.sequence === water.shift.sequence &&
1445
+ waterClaim.until > Date.now() &&
1446
+ waterClaim.until <= Date.now() + HYDRATION_LEASE_MS;
1447
+ const reactionSurface = [
1448
+ collapsed,
1449
+ editing,
1450
+ waterEditing,
1451
+ view.companion,
1452
+ view.companionMotion,
1453
+ waterVisible,
1454
+ ].join(':');
1455
+ if (reactionState.current?.token !== waterNotice?.token) {
1456
+ reactionState.current = {
1457
+ token: waterNotice?.token,
1458
+ surface: reactionSurface,
1459
+ stopped: ['quiet', 'paused', 'break'].includes(waterState.phase),
1460
+ };
1461
+ } else if (reactionState.current && reactionState.current.surface !== reactionSurface) {
1462
+ reactionState.current.stopped = true;
1463
+ }
1464
+ const companion = companionPresentation({
1465
+ workStatus: safeState?.status,
1466
+ phase: waterState.phase,
1467
+ ownsPrompt: waterPrompt,
1468
+ ready: waterReady && !!safeState,
1469
+ failed: waterError || !!error,
1470
+ feedback: waterNotice || null,
1471
+ visible: view.companion,
1472
+ motion: view.companionMotion && !reactionState.current?.stopped,
1473
+ pageVisible: waterVisible && !document.hidden && !editing && !waterEditing,
358
1474
  });
359
- const sessionSelect = () => h('select', {
360
- value: sessionId || '', disabled: busy,
361
- onChange: e => setSessionChoice(e.target.value),
362
- }, sessionIds.length
363
- ? sessionIds.map(id => h('option', { key: id, value: id }, sessions?.byId?.[id]?.title || String(id).slice(0, 12)))
364
- : h('option', { value: '' }, t('noSession')));
365
- const salaryToggle = () => h('button', {
366
- type: 'button', className: 'owc-text', 'aria-pressed': view.masked,
367
- title: t(view.masked ? 'show' : 'hide'),
368
- onClick: () => setPreference({ masked: !view.masked }),
369
- }, iconSvg(view.masked ? 'eyeOff' : 'eye', 13), t(view.masked ? 'show' : 'hide'));
370
-
371
- const strip = () => h('div', { className: 'owc-pill' },
372
- h('div', { className: 'owc-pill-top' },
373
- iconSvg('alarm', 16),
374
- h('span', { className: 'owc-grip', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys },
375
- safeState.status !== 'done' && h('span', { className: 'owc-pill-status' }, t(safeState.status)),
376
- h('span', { className: 'owc-pill-time' }, duration)),
377
- h('button', {
378
- type: 'button', className: 'owc-icon', style: { marginLeft: 'auto' },
379
- title: t('expand'), 'aria-label': t('expand'),
380
- onClick: () => setPreference({ mini: false }),
381
- }, iconSvg('chevronUp', 14))),
382
- h('div', { className: 'owc-pill-bottom' },
383
- h('span', { className: 'owc-pill-money', 'aria-label': view.masked ? t('masked') : moneyText }, moneyText),
384
- h('div', { className: 'owc-pill-actions' },
385
- h('button', {
386
- type: 'button', className: 'owc-icon',
387
- title: t(view.masked ? 'show' : 'hide'), 'aria-label': t(view.masked ? 'show' : 'hide'),
388
- onClick: () => setPreference({ masked: !view.masked }),
389
- }, iconSvg(view.masked ? 'eyeOff' : 'eye', 14)))));
390
-
391
- const panelHead = () => h('div', { className: 'owc-head' },
392
- h('span', {
393
- className: 'owc-title', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys,
394
- }, editing ? t(data?.configured ? 'settings' : 'setup') : t('title')),
395
- editing
396
- ? (data?.configured && h('button', {
397
- type: 'button', className: 'owc-icon', title: t('cancel'), 'aria-label': t('cancel'),
398
- onClick: closeEditor, disabled: busy,
399
- }, iconSvg('close')))
400
- : h(React.Fragment, null,
401
- h('button', {
402
- type: 'button', className: 'owc-icon', title: t('settings'), 'aria-label': t('settings'),
403
- onClick: openEditor, disabled: busy,
404
- }, iconSvg('sliders')),
405
- h('button', {
406
- type: 'button', className: 'owc-icon', title: t('collapse'), 'aria-label': t('collapse'),
407
- onClick: () => setPreference({ mini: true }),
408
- }, iconSvg('chevronDown'))));
409
-
410
- const panelBody = () => {
411
- if (!safeState) return h('div', { className: 'owc-main' },
412
- h('div', { className: 'owc-status' }, h('span', { className: 'owc-dot' }), t('loading')),
413
- !sessionId && h('p', { className: 'owc-hint' }, t('noSession')),
414
- h('div', { className: 'owc-row-actions' },
415
- h('button', { type: 'button', className: 'owc-text', onClick: () => reload(true), disabled: busy || !sessionId },
416
- iconSvg('refresh', 12), t('retry'))));
1475
+ const companionCard = (compact = false) =>
1476
+ companion.visible &&
1477
+ h(
1478
+ 'div',
1479
+ { className: 'owc-companion', 'data-companion': companion.pose },
1480
+ !compact && companionArtwork(h, companion, 52, waterNotice?.token),
1481
+ h(
1482
+ 'div',
1483
+ { className: 'owc-companion-copy' },
1484
+ h(
1485
+ 'div',
1486
+ { className: 'owc-companion-name' },
1487
+ t('companionName'),
1488
+ h('span', null, t('companionLabel')),
1489
+ ),
1490
+ companion.message &&
1491
+ h(
1492
+ 'p',
1493
+ {
1494
+ key: companion.announce
1495
+ ? 'feedback:' + (waterNotice?.token ?? companion.message)
1496
+ : 'ambient',
1497
+ },
1498
+ t(companion.message),
1499
+ ),
1500
+ ),
1501
+ );
1502
+ const waterSummary = () => {
1503
+ if (!waterReady) return t('waterLoading');
1504
+ if (waterError) return t('waterUnavailable');
1505
+ if (
1506
+ water.enabled &&
1507
+ waterContext &&
1508
+ water.shift &&
1509
+ water.shift.config !== waterContext.config
1510
+ )
1511
+ return t('waterStale');
1512
+ const labels = {
1513
+ off: 'waterOff',
1514
+ waiting: 'waterWaiting',
1515
+ break: 'waterBreak',
1516
+ paused: 'waterPaused',
1517
+ finished: 'waterFinished',
1518
+ due: waterPrompt ? 'waterDue' : 'waterOther',
1519
+ };
1520
+ if (waterState.phase === 'quiet')
1521
+ return t('waterQuiet') + ' · ' + formatDuration(waterState.quietRemaining / 1000);
1522
+ return waterState.phase === 'countdown'
1523
+ ? t('waterNext') + ' · ' + formatDuration(waterState.remaining / 1000)
1524
+ : t(labels[waterState.phase]);
1525
+ };
1526
+ const waterCanRecord =
1527
+ waterReady &&
1528
+ !waterError &&
1529
+ data?.configured &&
1530
+ ['working', 'lunch'].includes(waterContext?.status) &&
1531
+ (!water.shift || water.shift.config === waterContext.config);
1532
+ const waterCanQuiet = waterCanRecord && water.enabled && !water.shift?.paused;
1533
+ const recordWater = (event) => {
1534
+ if (event.detail > 1) return;
1535
+ if (!manualAttempt.current)
1536
+ manualAttempt.current = {
1537
+ type: 'manual-drink',
1538
+ shiftId: waterContext.id,
1539
+ actionId: window.crypto?.randomUUID?.() || String(Math.random()) + Date.now(),
1540
+ ...(waterState.phase === 'due' ? { eventId: waterState.eventId } : {}),
1541
+ };
1542
+ void waterAction(manualAttempt.current);
1543
+ };
1544
+ const waterControls = () =>
1545
+ h(
1546
+ 'div',
1547
+ { className: 'owc-water-actions' },
1548
+ !waterPrompt &&
1549
+ h(
1550
+ 'button',
1551
+ {
1552
+ type: 'button',
1553
+ className: 'owc-primary',
1554
+ disabled: !waterCanRecord || waterBusy,
1555
+ onClick: recordWater,
1556
+ },
1557
+ t('waterManual'),
1558
+ ),
1559
+ waterState.phase === 'quiet' &&
1560
+ h(
1561
+ 'button',
1562
+ {
1563
+ type: 'button',
1564
+ disabled: waterBusy || waterError,
1565
+ onClick: () => waterAction({ type: 'end-quiet' }),
1566
+ },
1567
+ t('waterQuietEnd'),
1568
+ ),
1569
+ waterCanQuiet &&
1570
+ h(
1571
+ 'details',
1572
+ { className: 'owc-water-quiet' },
1573
+ h(
1574
+ 'summary',
1575
+ null,
1576
+ t(waterState.phase === 'quiet' ? 'waterQuietAdjust' : 'waterQuiet'),
1577
+ ),
1578
+ h(
1579
+ 'div',
1580
+ { className: 'owc-water-actions' },
1581
+ [30, 60].map((minutes) =>
1582
+ h(
1583
+ 'button',
1584
+ {
1585
+ key: minutes,
1586
+ type: 'button',
1587
+ disabled: waterBusy,
1588
+ onClick: (event) => {
1589
+ const menu = event.currentTarget.closest('details');
1590
+ menu.open = false;
1591
+ menu.querySelector('summary')?.focus();
1592
+ void waterAction({ type: 'quiet', minutes });
1593
+ },
1594
+ },
1595
+ t(minutes === 30 ? 'waterQuiet30' : 'waterQuiet60'),
1596
+ ),
1597
+ ),
1598
+ ),
1599
+ waterState.phase !== 'quiet' &&
1600
+ h('p', { className: 'owc-hint' }, t('waterQuietHint')),
1601
+ ),
1602
+ water.enabled &&
1603
+ h(
1604
+ 'button',
1605
+ {
1606
+ type: 'button',
1607
+ disabled:
1608
+ waterError ||
1609
+ waterBusy ||
1610
+ !waterContext ||
1611
+ water.shift?.id !== waterContext.id,
1612
+ onClick: () => waterAction({ type: water.shift?.paused ? 'resume' : 'pause' }),
1613
+ },
1614
+ t(water.shift?.paused ? 'waterResume' : 'waterPause'),
1615
+ ),
1616
+ waterError &&
1617
+ h(
1618
+ 'button',
1619
+ {
1620
+ type: 'button',
1621
+ disabled: waterBusy,
1622
+ onClick: () => waterAction({ type: 'retry' }),
1623
+ },
1624
+ t('waterRetry'),
1625
+ ),
1626
+ );
1627
+
1628
+ const waterOverview = (quick = false) =>
1629
+ h(
1630
+ 'div',
1631
+ {
1632
+ className: 'owc-water owc-water-overview',
1633
+ 'data-water-overview': true,
1634
+ 'data-water-quick': quick || undefined,
1635
+ role: quick ? 'region' : undefined,
1636
+ 'aria-label': quick ? t('waterQuick') : undefined,
1637
+ onKeyDown: quick
1638
+ ? (event) => {
1639
+ if (event.key === 'Escape') {
1640
+ event.preventDefault();
1641
+ event.stopPropagation();
1642
+ closeWaterQuick();
1643
+ }
1644
+ }
1645
+ : undefined,
1646
+ },
1647
+ h(
1648
+ 'div',
1649
+ { className: 'owc-water-head' },
1650
+ h('strong', null, iconSvg('water', 14), t('waterTitle')),
1651
+ quick &&
1652
+ h(
1653
+ 'button',
1654
+ { type: 'button', className: 'owc-text', onClick: openAppearance },
1655
+ t('appearanceGroup'),
1656
+ ),
1657
+ h(
1658
+ 'button',
1659
+ {
1660
+ type: 'button',
1661
+ className: 'owc-text',
1662
+ style: { marginLeft: 'auto' },
1663
+ 'aria-label': t('waterEdit'),
1664
+ disabled: !waterReady || !data?.configured,
1665
+ onClick: openWaterEditor,
1666
+ },
1667
+ t('settings'),
1668
+ ),
1669
+ quick &&
1670
+ h(
1671
+ 'button',
1672
+ {
1673
+ type: 'button',
1674
+ className: 'owc-icon',
1675
+ 'aria-label': t('waterQuickClose'),
1676
+ title: t('waterQuickClose'),
1677
+ onClick: closeWaterQuick,
1678
+ },
1679
+ iconSvg('close', 14),
1680
+ ),
1681
+ ),
1682
+ quick && companionCard(true),
1683
+ !waterPrompt &&
1684
+ h(
1685
+ 'p',
1686
+ { className: 'owc-water-status', 'data-water-status': true },
1687
+ waterSummary(),
1688
+ ),
1689
+ h(
1690
+ 'div',
1691
+ { className: 'owc-water-meta' },
1692
+ h(
1693
+ 'span',
1694
+ { 'data-water-effective': water.interval },
1695
+ t('waterEvery') + ' ' + water.interval + ' ' + t('waterMinutes'),
1696
+ ),
1697
+ h('span', { 'data-water-count': true }, t('waterCount') + ': ' + waterState.count),
1698
+ ),
1699
+ waterControls(),
1700
+ !['working', 'lunch'].includes(waterContext?.status) &&
1701
+ h('p', { className: 'owc-hint' }, t('waterRecordHint')),
1702
+ waterState.phase === 'quiet' &&
1703
+ h('p', { className: 'owc-hint' }, t('waterQuietHint')),
1704
+ waterNotice && !view.companion && h('p', null, t(waterNotice.key)),
1705
+ );
1706
+
1707
+ const waterSettings = () =>
1708
+ h(
1709
+ 'form',
1710
+ {
1711
+ className: 'owc-water owc-water-form',
1712
+ 'data-water-settings': true,
1713
+ onSubmit: saveWaterSettings,
1714
+ noValidate: true,
1715
+ onKeyDown: (event) => {
1716
+ if (event.key === 'Escape') {
1717
+ event.preventDefault();
1718
+ navigateScreen(ui.back);
1719
+ }
1720
+ },
1721
+ },
1722
+ h(
1723
+ 'label',
1724
+ { className: 'owc-water-toggle' },
1725
+ h('span', null, t('waterEnable')),
1726
+ h('input', {
1727
+ type: 'checkbox',
1728
+ checked: waterEnabledDraft,
1729
+ disabled: !waterReady || waterError || waterBusy || !data?.configured,
1730
+ onChange: (event) => {
1731
+ setWaterEnabledDraft(event.target.checked);
1732
+ setWaterNotice(false);
1733
+ },
1734
+ }),
1735
+ ),
1736
+ h(
1737
+ 'fieldset',
1738
+ { disabled: !waterReady || waterError || waterBusy || !data?.configured },
1739
+ h('legend', { className: 'owc-muted' }, t('waterPresets')),
1740
+ h(
1741
+ 'div',
1742
+ { className: 'owc-water-presets' },
1743
+ [15, 30, 45, 60, 90, 120].map((minutes) =>
1744
+ h(
1745
+ 'button',
1746
+ {
1747
+ type: 'button',
1748
+ key: minutes,
1749
+ 'aria-pressed': Number(waterInterval) === minutes,
1750
+ onClick: () => {
1751
+ setWaterInterval(String(minutes));
1752
+ setWaterInvalid(false);
1753
+ },
1754
+ },
1755
+ minutes + ' ' + t('waterMinutes'),
1756
+ ),
1757
+ ),
1758
+ ),
1759
+ h(
1760
+ 'label',
1761
+ { className: 'owc-field' },
1762
+ h('span', null, t('waterInterval')),
1763
+ h('input', {
1764
+ name: 'waterInterval',
1765
+ type: 'number',
1766
+ inputMode: 'numeric',
1767
+ min: '5',
1768
+ max: '240',
1769
+ step: '1',
1770
+ value: waterInterval,
1771
+ 'aria-invalid': waterInvalid,
1772
+ onChange: (event) => {
1773
+ setWaterInterval(event.target.value);
1774
+ setWaterInvalid(false);
1775
+ },
1776
+ }),
1777
+ ),
1778
+ ),
1779
+ waterInvalid && h('p', { role: 'alert' }, t('waterInvalid')),
1780
+ h(
1781
+ 'p',
1782
+ { 'data-water-saved-interval': water.interval },
1783
+ t('waterEffective') + ': ' + water.interval + ' ' + t('waterMinutes'),
1784
+ ),
1785
+ h('p', { className: 'owc-hint' }, t('waterSaveHint')),
1786
+ h('p', { className: 'owc-hint' }, t('waterBrief')),
1787
+ waterError && h('p', { role: 'alert' }, t('waterUnavailable')),
1788
+ !data?.configured && h('p', null, t('waterSetup')),
1789
+ h(
1790
+ 'div',
1791
+ { className: 'owc-save' },
1792
+ h(
1793
+ 'button',
1794
+ { type: 'button', disabled: waterBusy, onClick: closeWaterEditor },
1795
+ t('cancel'),
1796
+ ),
1797
+ h(
1798
+ 'button',
1799
+ {
1800
+ type: 'submit',
1801
+ className: 'owc-primary',
1802
+ disabled: !waterReady || waterError || waterBusy || !data?.configured,
1803
+ },
1804
+ t(waterBusy ? 'busy' : 'waterSave'),
1805
+ ),
1806
+ ),
1807
+ waterError &&
1808
+ h(
1809
+ 'button',
1810
+ {
1811
+ type: 'button',
1812
+ disabled: waterBusy,
1813
+ onClick: () => waterAction({ type: 'retry' }),
1814
+ },
1815
+ t('waterRetry'),
1816
+ ),
1817
+ h(
1818
+ 'details',
1819
+ null,
1820
+ h('summary', null, t('waterManage')),
1821
+ h('p', null, t('waterLocal')),
1822
+ h(
1823
+ 'div',
1824
+ { className: 'owc-water-actions' },
1825
+ h(
1826
+ 'button',
1827
+ {
1828
+ type: 'button',
1829
+ disabled: waterError || waterBusy || !waterState.count,
1830
+ onClick: () => setWaterConfirm('clear-count'),
1831
+ },
1832
+ t('waterClear'),
1833
+ ),
1834
+ h(
1835
+ 'button',
1836
+ {
1837
+ type: 'button',
1838
+ disabled: waterBusy || !waterReady,
1839
+ onClick: () => setWaterConfirm('reset'),
1840
+ },
1841
+ t('waterReset'),
1842
+ ),
1843
+ ),
1844
+ waterConfirm &&
1845
+ h(
1846
+ 'div',
1847
+ { className: 'owc-water-confirm' },
1848
+ h('p', null, t(waterConfirm === 'reset' ? 'waterResetHint' : 'waterClearHint')),
1849
+ h(
1850
+ 'div',
1851
+ { className: 'owc-water-actions' },
1852
+ h(
1853
+ 'button',
1854
+ {
1855
+ type: 'button',
1856
+ disabled: waterBusy,
1857
+ onClick: () => waterAction({ type: waterConfirm }),
1858
+ },
1859
+ t('waterConfirm'),
1860
+ ),
1861
+ h(
1862
+ 'button',
1863
+ { type: 'button', onClick: () => setWaterConfirm(null) },
1864
+ t('cancel'),
1865
+ ),
1866
+ ),
1867
+ ),
1868
+ ),
1869
+ );
1870
+ const waterReminder = () =>
1871
+ waterPrompt &&
1872
+ h(
1873
+ 'div',
1874
+ { className: 'owc-water owc-water-due', 'data-water-event': waterState.eventId },
1875
+ h('p', null, iconSvg('water', 14), ' ' + t('waterDue')),
1876
+ h(
1877
+ 'div',
1878
+ { className: 'owc-water-actions' },
1879
+ h(
1880
+ 'button',
1881
+ {
1882
+ type: 'button',
1883
+ disabled: waterBusy,
1884
+ onClick: () => waterAction({ type: 'drink', eventId: waterState.eventId }),
1885
+ },
1886
+ t('waterDrink'),
1887
+ ),
1888
+ h(
1889
+ 'button',
1890
+ {
1891
+ type: 'button',
1892
+ disabled: waterBusy,
1893
+ onClick: () => waterAction({ type: 'snooze', eventId: waterState.eventId }),
1894
+ },
1895
+ t('waterSnooze'),
1896
+ ),
1897
+ ),
1898
+ );
1899
+
1900
+ const strip = () =>
1901
+ h(
1902
+ 'div',
1903
+ { className: 'owc-pill' },
1904
+ h(
1905
+ 'div',
1906
+ { className: 'owc-pill-top' },
1907
+ companion.visible
1908
+ ? h(
1909
+ 'button',
1910
+ {
1911
+ type: 'button',
1912
+ className: 'owc-companion-trigger',
1913
+ 'aria-label': t('companionOpen'),
1914
+ 'aria-expanded': waterQuickOpen,
1915
+ title:
1916
+ t('companionName') +
1917
+ (companion.message ? ' · ' + t(companion.message) : ''),
1918
+ onClick: toggleWaterQuick,
1919
+ onKeyDown: (event) => {
1920
+ if (event.key === 'Escape' && waterQuickOpen) {
1921
+ event.preventDefault();
1922
+ closeWaterQuick();
1923
+ }
1924
+ },
1925
+ },
1926
+ companionArtwork(h, companion, 36, waterNotice?.token),
1927
+ )
1928
+ : iconSvg('alarm', 16),
1929
+ h(
1930
+ 'span',
1931
+ { className: 'owc-grip', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys },
1932
+ safeState.status !== 'done' &&
1933
+ h('span', { className: 'owc-pill-status' }, t(safeState.status)),
1934
+ h('span', { className: 'owc-pill-time' }, duration),
1935
+ ),
1936
+ h(
1937
+ 'button',
1938
+ {
1939
+ type: 'button',
1940
+ className: 'owc-icon',
1941
+ style: { marginLeft: 'auto' },
1942
+ title: t('expand'),
1943
+ 'aria-label': t('expand'),
1944
+ onClick: () => {
1945
+ setWaterQuickOpen(false);
1946
+ setPreference({ mini: false });
1947
+ },
1948
+ },
1949
+ iconSvg('chevronUp', 14),
1950
+ ),
1951
+ ),
1952
+ waterPrompt
1953
+ ? waterReminder()
1954
+ : h(
1955
+ 'div',
1956
+ { className: 'owc-pill-bottom' },
1957
+ h(
1958
+ 'div',
1959
+ { className: 'owc-pill-salary' },
1960
+ h(
1961
+ 'span',
1962
+ {
1963
+ className: 'owc-pill-money',
1964
+ 'aria-label': view.masked ? t('masked') : moneyText,
1965
+ },
1966
+ moneyText,
1967
+ ),
1968
+ h(
1969
+ 'button',
1970
+ {
1971
+ type: 'button',
1972
+ className: 'owc-icon',
1973
+ title: t(view.masked ? 'show' : 'hide'),
1974
+ 'aria-label': t(view.masked ? 'show' : 'hide'),
1975
+ onClick: () => setPreference({ masked: !view.masked }),
1976
+ },
1977
+ iconSvg(view.masked ? 'eyeOff' : 'eye', 13),
1978
+ ),
1979
+ ),
1980
+ (water.enabled || waterError) &&
1981
+ h(
1982
+ 'button',
1983
+ {
1984
+ type: 'button',
1985
+ className: 'owc-pill-water',
1986
+ onClick: toggleWaterQuick,
1987
+ 'aria-label': t('waterQuick'),
1988
+ 'aria-expanded': waterQuickOpen,
1989
+ onKeyDown: (event) => {
1990
+ if (event.key === 'Escape' && waterQuickOpen) {
1991
+ event.preventDefault();
1992
+ closeWaterQuick();
1993
+ }
1994
+ },
1995
+ title: waterSummary(),
1996
+ },
1997
+ iconSvg('water', 13),
1998
+ waterState.phase === 'quiet' && !waterError
1999
+ ? t('waterQuiet') +
2000
+ ' ' +
2001
+ formatDuration(waterState.quietRemaining / 1000).replace(/^00:/, '')
2002
+ : waterState.phase === 'countdown' && !waterError
2003
+ ? formatDuration(waterState.remaining / 1000).replace(/^00:/, '')
2004
+ : t(
2005
+ waterError
2006
+ ? 'waterTitle'
2007
+ : {
2008
+ paused: 'waterPaused',
2009
+ break: 'lunch',
2010
+ waiting: 'waterWaiting',
2011
+ finished: 'waterFinished',
2012
+ due: 'waterDue',
2013
+ off: 'waterOff',
2014
+ }[waterState.phase],
2015
+ ),
2016
+ ),
2017
+ ),
2018
+ );
2019
+
2020
+ const panelHead = () =>
2021
+ h(
2022
+ 'div',
2023
+ { className: 'owc-head' },
2024
+ h(
2025
+ 'span',
2026
+ {
2027
+ className: 'owc-title',
2028
+ tabIndex: 0,
2029
+ title: t('drag'),
2030
+ onKeyDown: gripKeys,
2031
+ },
2032
+ waterEditing
2033
+ ? t('waterTitle')
2034
+ : editing
2035
+ ? t(
2036
+ appearanceOnly
2037
+ ? 'appearanceGroup'
2038
+ : data?.configured
2039
+ ? 'settings'
2040
+ : 'setup',
2041
+ )
2042
+ : petMode
2043
+ ? t('companionName')
2044
+ : t('title'),
2045
+ ),
2046
+ !editing &&
2047
+ !waterEditing &&
2048
+ data?.configured &&
2049
+ h(
2050
+ 'button',
2051
+ {
2052
+ type: 'button',
2053
+ className: 'owc-text',
2054
+ onClick: openAppearance,
2055
+ },
2056
+ t('appearanceGroup'),
2057
+ ),
2058
+ waterEditing
2059
+ ? h(
2060
+ 'button',
2061
+ {
2062
+ type: 'button',
2063
+ className: 'owc-icon',
2064
+ title: t('waterBack'),
2065
+ 'aria-label': t('waterBack'),
2066
+ onClick: closeWaterEditor,
2067
+ disabled: waterBusy,
2068
+ },
2069
+ iconSvg('close'),
2070
+ )
2071
+ : editing
2072
+ ? data?.configured &&
2073
+ h(
2074
+ 'button',
2075
+ {
2076
+ type: 'button',
2077
+ className: 'owc-icon',
2078
+ title: t(appearanceOnly ? 'waterBack' : 'cancel'),
2079
+ 'aria-label': t(appearanceOnly ? 'waterBack' : 'cancel'),
2080
+ onClick: closeEditor,
2081
+ disabled: busy,
2082
+ },
2083
+ iconSvg('close'),
2084
+ )
2085
+ : h(
2086
+ React.Fragment,
2087
+ null,
2088
+ h(
2089
+ 'button',
2090
+ {
2091
+ type: 'button',
2092
+ className: 'owc-icon',
2093
+ title: t('settings'),
2094
+ 'aria-label': t('settings'),
2095
+ onClick: openEditor,
2096
+ disabled: busy,
2097
+ },
2098
+ iconSvg('sliders'),
2099
+ ),
2100
+ h(
2101
+ 'button',
2102
+ {
2103
+ type: 'button',
2104
+ className: 'owc-icon',
2105
+ title: t(petMode ? 'petClose' : 'collapse'),
2106
+ 'aria-label': t(petMode ? 'petClose' : 'collapse'),
2107
+ onClick: () =>
2108
+ petMode ? closePetPanel() : setPreference({ mini: true }),
2109
+ },
2110
+ iconSvg('chevronDown'),
2111
+ ),
2112
+ ),
2113
+ );
2114
+
2115
+ const panelBody = (section = 'all') => {
2116
+ if (!safeState)
2117
+ return h(
2118
+ 'div',
2119
+ { className: 'owc-main' },
2120
+ h(
2121
+ 'div',
2122
+ { className: 'owc-status' },
2123
+ h('span', { className: 'owc-dot' }),
2124
+ t('loading'),
2125
+ ),
2126
+ !sessionId && h('p', { className: 'owc-hint' }, t('noSession')),
2127
+ h(
2128
+ 'div',
2129
+ { className: 'owc-row-actions' },
2130
+ h(
2131
+ 'button',
2132
+ {
2133
+ type: 'button',
2134
+ className: 'owc-text',
2135
+ onClick: () => reload(true),
2136
+ disabled: busy || !sessionId,
2137
+ },
2138
+ iconSvg('refresh', 12),
2139
+ t('retry'),
2140
+ ),
2141
+ ),
2142
+ );
417
2143
  const status = safeState.status === 'done' ? '' : t(safeState.status);
418
- return h('div', { className: 'owc-main' },
419
- status && h('div', { className: 'owc-status' }, h('span', { className: 'owc-dot' }), status),
420
- h('div', { className: 'owc-time', 'aria-label': status + ' ' + duration }, duration),
421
- h('div', {
422
- className: 'owc-progress', role: 'progressbar', 'aria-label': t('progress'),
423
- 'aria-valuenow': Math.floor(safeState.progress * 100), 'aria-valuemin': 0, 'aria-valuemax': 100,
424
- }, h('div', { style: { width: safeState.progress * 100 + '%' } })),
425
- h('div', { className: 'owc-earn-row' },
426
- h('span', { className: 'owc-muted' }, t('earned')), salaryToggle()),
427
- h('div', { className: 'owc-money', 'aria-label': view.masked ? t('masked') : moneyText }, moneyText),
428
- h('div', { className: 'owc-stats' },
429
- h('div', null, h('span', null, t('daily')), h('strong', null, money(safeState.daily))),
430
- h('div', null, h('span', null, t('hourly')), h('strong', null, money(safeState.hourly)))),
431
- h('div', { className: 'owc-meta' },
432
- h('span', null, settings.start + ' — ' + settings.end + (settings.end < settings.start ? ' +1' : '') +
433
- ' · ' + (settings.breakStart === settings.breakEnd ? t('noBreak') : settings.breakStart + '–' + settings.breakEnd)),
434
- h('span', null, t('shiftDate') + ' ' + safeState.shiftDate + ' · ' + settings.timezone)),
435
- h('div', { className: 'owc-row-actions' },
436
- h('button', { type: 'button', className: 'owc-text', onClick: () => reload(true), disabled: busy },
437
- iconSvg('refresh', 12), t('refresh')),
438
- h('button', { type: 'button', className: 'owc-text', onClick: () => setPreference({ position: null }) }, t('reset'))));
2144
+ return h(
2145
+ 'div',
2146
+ { className: 'owc-main' },
2147
+ section !== 'earnings' &&
2148
+ status &&
2149
+ h('div', { className: 'owc-status' }, h('span', { className: 'owc-dot' }), status),
2150
+ section !== 'earnings' &&
2151
+ h(
2152
+ 'div',
2153
+ { className: 'owc-time', 'aria-label': status + ' ' + duration },
2154
+ duration,
2155
+ ),
2156
+ section !== 'earnings' &&
2157
+ h(
2158
+ 'div',
2159
+ {
2160
+ className: 'owc-progress',
2161
+ role: 'progressbar',
2162
+ 'aria-label': t('progress'),
2163
+ 'aria-valuenow': Math.floor(safeState.progress * 100),
2164
+ 'aria-valuemin': 0,
2165
+ 'aria-valuemax': 100,
2166
+ },
2167
+ h('div', { style: { width: safeState.progress * 100 + '%' } }),
2168
+ ),
2169
+ section !== 'clock' &&
2170
+ h(
2171
+ 'div',
2172
+ { className: 'owc-earn-row' },
2173
+ h('span', { className: 'owc-muted' }, t('earned')),
2174
+ salaryToggle(),
2175
+ ),
2176
+ section !== 'clock' &&
2177
+ h(
2178
+ 'div',
2179
+ { className: 'owc-money', 'aria-label': view.masked ? t('masked') : moneyText },
2180
+ moneyText,
2181
+ ),
2182
+ section !== 'clock' &&
2183
+ h(
2184
+ 'div',
2185
+ { className: 'owc-stats' },
2186
+ h(
2187
+ 'div',
2188
+ null,
2189
+ h('span', null, t('daily')),
2190
+ h('strong', null, money(safeState.daily)),
2191
+ ),
2192
+ h(
2193
+ 'div',
2194
+ null,
2195
+ h('span', null, t('hourly')),
2196
+ h('strong', null, money(safeState.hourly)),
2197
+ ),
2198
+ ),
2199
+ h(
2200
+ 'div',
2201
+ { className: 'owc-meta' },
2202
+ h(
2203
+ 'span',
2204
+ null,
2205
+ settings.start +
2206
+ ' — ' +
2207
+ settings.end +
2208
+ (settings.end < settings.start ? ' +1' : '') +
2209
+ ' · ' +
2210
+ (settings.breakStart === settings.breakEnd
2211
+ ? t('noBreak')
2212
+ : settings.breakStart + '–' + settings.breakEnd),
2213
+ ),
2214
+ h(
2215
+ 'span',
2216
+ null,
2217
+ t('shiftDate') + ' ' + safeState.shiftDate + ' · ' + settings.timezone,
2218
+ ),
2219
+ ),
2220
+ h(
2221
+ 'div',
2222
+ { className: 'owc-row-actions' },
2223
+ h(
2224
+ 'button',
2225
+ {
2226
+ type: 'button',
2227
+ className: 'owc-text',
2228
+ onClick: () => reload(true),
2229
+ disabled: busy,
2230
+ },
2231
+ iconSvg('refresh', 12),
2232
+ t('refresh'),
2233
+ ),
2234
+ petMode &&
2235
+ h(
2236
+ 'button',
2237
+ { type: 'button', className: 'owc-text', onClick: openEditor },
2238
+ t('petWorkSettings'),
2239
+ ),
2240
+ !petMode &&
2241
+ h(
2242
+ 'button',
2243
+ {
2244
+ type: 'button',
2245
+ className: 'owc-text',
2246
+ onClick: () =>
2247
+ setPreference(petMode ? { petPosition: null } : { position: null }),
2248
+ },
2249
+ t('reset'),
2250
+ ),
2251
+ ),
2252
+ );
439
2253
  };
440
2254
 
2255
+ const appearanceFields = () =>
2256
+ h(
2257
+ 'fieldset',
2258
+ { className: 'owc-setting-group' },
2259
+ h('legend', null, t('appearanceGroup')),
2260
+ h(
2261
+ 'label',
2262
+ { className: 'owc-check' },
2263
+ h('input', {
2264
+ type: 'checkbox',
2265
+ name: 'companion',
2266
+ checked: view.companion,
2267
+ onChange: (event) => updateAppearance({ companion: event.target.checked }),
2268
+ }),
2269
+ t('petMode'),
2270
+ ),
2271
+ h(
2272
+ 'label',
2273
+ { className: 'owc-check' },
2274
+ h('input', {
2275
+ type: 'checkbox',
2276
+ checked: view.companionMotion,
2277
+ disabled: !view.companion,
2278
+ onChange: (event) => updateAppearance({ companionMotion: event.target.checked }),
2279
+ }),
2280
+ t('companionMotion'),
2281
+ ),
2282
+ label(
2283
+ t('petCharacter'),
2284
+ h(
2285
+ 'select',
2286
+ {
2287
+ 'aria-label': t('petCharacter'),
2288
+ value: view.petModel,
2289
+ disabled: !view.companion,
2290
+ onChange: (event) => updateAppearance({ petModel: event.target.value }),
2291
+ },
2292
+ ...Object.values(PET_MODELS).map((model) =>
2293
+ h('option', { key: model.id, value: model.id }, t(model.label)),
2294
+ ),
2295
+ ),
2296
+ ),
2297
+ label(
2298
+ t('petSize'),
2299
+ h('input', {
2300
+ type: 'range',
2301
+ min: '80',
2302
+ max: '180',
2303
+ step: '4',
2304
+ value: view.petSize,
2305
+ 'aria-label': t('petSize'),
2306
+ disabled: !view.companion,
2307
+ onChange: (event) => updateAppearance({ petSize: event.target.valueAsNumber }),
2308
+ }),
2309
+ ),
2310
+ h('p', { className: 'owc-hint' }, t('petAppearanceHint')),
2311
+ label(
2312
+ t('opacity'),
2313
+ h(
2314
+ React.Fragment,
2315
+ null,
2316
+ h('input', {
2317
+ className: 'owc-range',
2318
+ type: 'range',
2319
+ min: '30',
2320
+ max: '100',
2321
+ step: '1',
2322
+ value: String(Math.round(view.opacity * 100)),
2323
+ disabled: busy,
2324
+ 'aria-label': t('opacity'),
2325
+ onChange: (event) =>
2326
+ updateAppearance({ opacity: event.target.valueAsNumber / 100 }),
2327
+ }),
2328
+ h(
2329
+ 'span',
2330
+ { className: 'owc-hint', style: { margin: 0 } },
2331
+ Math.round(view.opacity * 100) + '% · ' + t('opacityHint'),
2332
+ ),
2333
+ ),
2334
+ ),
2335
+ );
441
2336
  const editor = () => {
2337
+ if (appearanceOnly)
2338
+ return h(
2339
+ 'div',
2340
+ { className: 'owc-form owc-appearance', 'data-appearance-settings': true },
2341
+ h('p', { className: 'owc-hint' }, t('petAppearancePreview')),
2342
+ h(
2343
+ 'div',
2344
+ { 'data-appearance-preview': true, className: 'owc-appearance-preview' },
2345
+ view.companion
2346
+ ? petArtwork(
2347
+ h,
2348
+ companion,
2349
+ view.petSize * (petModel(view.petModel).renderer === 'image' ? 0.65 : 1),
2350
+ '',
2351
+ view.petModel,
2352
+ PET_SPRITES,
2353
+ )
2354
+ : h('p', null, t('petClassicPreview')),
2355
+ ),
2356
+ appearanceFields(),
2357
+ h(
2358
+ 'div',
2359
+ { className: 'owc-save' },
2360
+ h('button', { type: 'button', onClick: closeEditor }, t('cancel')),
2361
+ h(
2362
+ 'button',
2363
+ { type: 'button', className: 'owc-primary', onClick: applyAppearance },
2364
+ t('petApplyAppearance'),
2365
+ ),
2366
+ ),
2367
+ );
442
2368
  if (!workingDraft) return null;
443
2369
  const payDaily = workingDraft.payMode === 'daily';
444
2370
  const salaryKey = payDaily ? 'dailySalary' : 'monthlySalary';
445
2371
  const salaryControl = view.masked
446
- ? h('div', { className: 'owc-private' }, h('span', null, '••••'),
447
- h('button', { type: 'button', className: 'owc-text', onClick: () => setPreference({ masked: false }) }, t('show')))
2372
+ ? h(
2373
+ 'div',
2374
+ { className: 'owc-private' },
2375
+ h('span', null, '••••'),
2376
+ h(
2377
+ 'button',
2378
+ {
2379
+ type: 'button',
2380
+ className: 'owc-text',
2381
+ 'aria-label': t('show'),
2382
+ onClick: () => setPreference({ masked: false }),
2383
+ },
2384
+ t('show'),
2385
+ ),
2386
+ )
448
2387
  : numberInput(salaryKey);
449
2388
  const breakOn = workingDraft.breakStart !== workingDraft.breakEnd;
450
- const commonZones = ['Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Taipei', 'Asia/Singapore', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Kolkata', 'Europe/London', 'Europe/Paris', 'America/New_York', 'America/Los_Angeles', 'Australia/Sydney', 'UTC'];
2389
+ const commonZones = [
2390
+ 'Asia/Shanghai',
2391
+ 'Asia/Hong_Kong',
2392
+ 'Asia/Taipei',
2393
+ 'Asia/Singapore',
2394
+ 'Asia/Tokyo',
2395
+ 'Asia/Seoul',
2396
+ 'Asia/Kolkata',
2397
+ 'Europe/London',
2398
+ 'Europe/Paris',
2399
+ 'America/New_York',
2400
+ 'America/Los_Angeles',
2401
+ 'Australia/Sydney',
2402
+ 'UTC',
2403
+ ];
451
2404
  let allZones = [];
452
- try { allZones = Intl.supportedValuesOf('timeZone'); } catch {}
453
- const zones = [...new Set([workingDraft.timezone, ...commonZones, ...allZones].filter(Boolean))];
454
- return h('form', {
455
- className: 'owc-form', onSubmit: save,
456
- onKeyDown: e => { if (e.key === 'Escape') { e.preventDefault(); closeEditor(); } },
457
- },
458
- !data?.configured && h('p', { className: 'owc-hint' }, t('setupHint')),
459
- h('fieldset', { className: 'owc-setting-group' }, h('legend', null, t('salaryGroup')),
460
- label(t('payMode'), h('div', { className: 'owc-seg' },
461
- h('button', { type: 'button', disabled: busy, 'aria-pressed': !payDaily, onClick: () => update('payMode', 'monthly') }, t('monthly')),
462
- h('button', { type: 'button', disabled: busy, 'aria-pressed': payDaily, onClick: () => update('payMode', 'daily') }, t('dailyMode')))),
463
- h('div', { className: 'owc-row2' },
464
- label(t(salaryKey), salaryControl),
465
- !payDaily && label(t('workdaysPerMonth'), numberInput('workdaysPerMonth')))),
466
- h('fieldset', { className: 'owc-setting-group' }, h('legend', null, t('scheduleGroup')),
467
- h('div', { className: 'owc-row2' }, label(t('start'), timeInput('start')), label(t('end'), timeInput('end'))),
468
- workingDraft.end < workingDraft.start && h('p', { className: 'owc-hint' }, t('end') + ' · ' + t('nextDay')),
469
- h('label', { className: 'owc-check' },
470
- h('input', { type: 'checkbox', checked: breakOn, disabled: busy, onChange: toggleBreak }), t('breakEnabled')),
471
- breakOn && h('div', { className: 'owc-row2' }, label(t('breakStart'), timeInput('breakStart')), label(t('breakEnd'), timeInput('breakEnd'))),
472
- label(t('weekdays'), h('div', { className: 'owc-days' }, [0, 1, 2, 3, 4, 5, 6].map(day => h('button', {
473
- key: day, type: 'button', disabled: busy, 'aria-pressed': workingDraft.weekdays.includes(day),
474
- 'aria-label': t('weekdays') + ' ' + t('days')[day],
475
- onClick: () => update('weekdays', workingDraft.weekdays.includes(day)
476
- ? workingDraft.weekdays.filter(d => d !== day)
477
- : [...workingDraft.weekdays, day].sort((a, b) => a - b)),
478
- }, t('days')[day])))),
479
- label(t('timezone'), h('select', {
480
- name: 'timezone', value: workingDraft.timezone ?? '', disabled: busy,
481
- onChange: e => update('timezone', e.target.value),
482
- }, zones.map(zone => h('option', { key: zone, value: zone }, zone))))),
483
- h('fieldset', { className: 'owc-setting-group' }, h('legend', null, t('appearanceGroup')),
484
- label(t('opacity'), h(React.Fragment, null,
485
- h('input', {
486
- className: 'owc-range', type: 'range', min: '30', max: '100', step: '5',
487
- value: String(Math.round(view.opacity * 100)), disabled: busy, 'aria-label': t('opacity'),
488
- onChange: e => setPreference({ opacity: e.target.valueAsNumber / 100 }),
489
- }),
490
- h('span', { className: 'owc-hint', style: { margin: 0 } }, Math.round(view.opacity * 100) + '% · ' + t('opacityHint'))))),
491
- preview && !preview.error && h('div', { className: 'owc-preview' },
492
- t('preview') + ' · ' + t('daily') + ' ' + money(preview.daily) + ' · ' + t('hourly') + ' ' + money(preview.hourly)),
493
- preview?.error && h('p', { className: 'owc-error', role: 'alert', style: { padding: 0 } }, preview.error),
494
- h('details', { className: 'owc-advanced' },
495
- h('summary', null, t('advanced')),
496
- h('p', { className: 'owc-hint' }, t('sessionHint')),
497
- label(t('session'), sessionSelect())),
498
- h('p', { className: 'owc-hint' }, t('privacy')),
499
- h('div', { className: 'owc-save' },
500
- data?.configured && h('button', { type: 'button', className: 'owc-text', onClick: closeEditor, disabled: busy }, t('cancel')),
501
- h('button', { type: 'submit', className: 'owc-primary', disabled: busy || !!preview?.error }, t(busy ? 'busy' : 'save'))));
2405
+ try {
2406
+ allZones = Intl.supportedValuesOf('timeZone');
2407
+ } catch {}
2408
+ const zones = [
2409
+ ...new Set([workingDraft.timezone, ...commonZones, ...allZones].filter(Boolean)),
2410
+ ];
2411
+ return h(
2412
+ 'form',
2413
+ {
2414
+ className: 'owc-form',
2415
+ onSubmit: save,
2416
+ onKeyDown: (e) => {
2417
+ if (e.key === 'Escape') {
2418
+ e.preventDefault();
2419
+ navigateScreen(ui.back);
2420
+ }
2421
+ },
2422
+ },
2423
+ !data?.configured && h('p', { className: 'owc-hint' }, t('setupHint')),
2424
+ h(
2425
+ 'fieldset',
2426
+ { className: 'owc-setting-group' },
2427
+ h('legend', null, t('salaryGroup')),
2428
+ label(
2429
+ t('payMode'),
2430
+ h(
2431
+ 'div',
2432
+ { className: 'owc-seg' },
2433
+ h(
2434
+ 'button',
2435
+ {
2436
+ type: 'button',
2437
+ disabled: busy,
2438
+ 'aria-pressed': !payDaily,
2439
+ 'aria-label': t('monthly'),
2440
+ onClick: () => update('payMode', 'monthly'),
2441
+ },
2442
+ t('monthly'),
2443
+ ),
2444
+ h(
2445
+ 'button',
2446
+ {
2447
+ type: 'button',
2448
+ disabled: busy,
2449
+ 'aria-pressed': payDaily,
2450
+ 'aria-label': t('dailyMode'),
2451
+ onClick: () => update('payMode', 'daily'),
2452
+ },
2453
+ t('dailyMode'),
2454
+ ),
2455
+ ),
2456
+ ),
2457
+ h(
2458
+ 'div',
2459
+ { className: 'owc-row2' },
2460
+ label(t(salaryKey), salaryControl),
2461
+ !payDaily && label(t('workdaysPerMonth'), numberInput('workdaysPerMonth')),
2462
+ ),
2463
+ ),
2464
+ h(
2465
+ 'fieldset',
2466
+ { className: 'owc-setting-group' },
2467
+ h('legend', null, t('scheduleGroup')),
2468
+ h(
2469
+ 'div',
2470
+ { className: 'owc-row2' },
2471
+ label(t('start'), timeInput('start')),
2472
+ label(t('end'), timeInput('end')),
2473
+ ),
2474
+ workingDraft.end < workingDraft.start &&
2475
+ h('p', { className: 'owc-hint' }, t('end') + ' · ' + t('nextDay')),
2476
+ h(
2477
+ 'label',
2478
+ { className: 'owc-check' },
2479
+ h('input', {
2480
+ type: 'checkbox',
2481
+ checked: breakOn,
2482
+ disabled: busy,
2483
+ onChange: toggleBreak,
2484
+ }),
2485
+ t('breakEnabled'),
2486
+ ),
2487
+ breakOn &&
2488
+ h(
2489
+ 'div',
2490
+ { className: 'owc-row2' },
2491
+ label(t('breakStart'), timeInput('breakStart')),
2492
+ label(t('breakEnd'), timeInput('breakEnd')),
2493
+ ),
2494
+ label(
2495
+ t('weekdays'),
2496
+ h(
2497
+ 'div',
2498
+ { className: 'owc-days' },
2499
+ [0, 1, 2, 3, 4, 5, 6].map((day) =>
2500
+ h(
2501
+ 'button',
2502
+ {
2503
+ key: day,
2504
+ type: 'button',
2505
+ disabled: busy,
2506
+ 'aria-pressed': workingDraft.weekdays.includes(day),
2507
+ 'aria-label': t('weekdays') + ' ' + t('days')[day],
2508
+ onClick: () =>
2509
+ update(
2510
+ 'weekdays',
2511
+ workingDraft.weekdays.includes(day)
2512
+ ? workingDraft.weekdays.filter((d) => d !== day)
2513
+ : [...workingDraft.weekdays, day].sort((a, b) => a - b),
2514
+ ),
2515
+ },
2516
+ t('days')[day],
2517
+ ),
2518
+ ),
2519
+ ),
2520
+ ),
2521
+ label(
2522
+ t('timezone'),
2523
+ h(
2524
+ 'select',
2525
+ {
2526
+ name: 'timezone',
2527
+ value: workingDraft.timezone ?? '',
2528
+ disabled: busy,
2529
+ onChange: (e) => update('timezone', e.target.value),
2530
+ },
2531
+ zones.map((zone) => h('option', { key: zone, value: zone }, zone)),
2532
+ ),
2533
+ ),
2534
+ ),
2535
+ preview &&
2536
+ !preview.error &&
2537
+ h(
2538
+ 'div',
2539
+ { className: 'owc-preview' },
2540
+ t('preview') +
2541
+ ' · ' +
2542
+ t('daily') +
2543
+ ' ' +
2544
+ money(preview.daily) +
2545
+ ' · ' +
2546
+ t('hourly') +
2547
+ ' ' +
2548
+ money(preview.hourly),
2549
+ ),
2550
+ preview?.error &&
2551
+ h(
2552
+ 'p',
2553
+ { className: 'owc-error', role: 'alert', style: { padding: 0 } },
2554
+ preview.error,
2555
+ ),
2556
+ h(
2557
+ 'details',
2558
+ { className: 'owc-advanced' },
2559
+ h('summary', null, t('advanced')),
2560
+ h('p', { className: 'owc-hint' }, t('sessionHint')),
2561
+ label(t('session'), sessionSelect()),
2562
+ ),
2563
+ h('p', { className: 'owc-hint' }, t('privacy')),
2564
+ h(
2565
+ 'div',
2566
+ { className: 'owc-save' },
2567
+ data?.configured &&
2568
+ h(
2569
+ 'button',
2570
+ { type: 'button', className: 'owc-text', onClick: closeEditor, disabled: busy },
2571
+ t('cancel'),
2572
+ ),
2573
+ h(
2574
+ 'button',
2575
+ { type: 'submit', className: 'owc-primary', disabled: busy || !!preview?.error },
2576
+ t(busy ? 'busy' : 'save'),
2577
+ ),
2578
+ ),
2579
+ );
2580
+ };
2581
+
2582
+ const notices = () =>
2583
+ h(
2584
+ React.Fragment,
2585
+ null,
2586
+ error && h('p', { className: 'owc-error', role: 'alert' }, error),
2587
+ state?.error && h('p', { className: 'owc-error', role: 'alert' }, state.error),
2588
+ notice && h('p', { className: 'owc-note', role: 'status' }, notice),
2589
+ storageError && h('p', { className: 'owc-note' }, t('storage')),
2590
+ );
2591
+
2592
+ function closePetPanel() {
2593
+ navigateScreen(null);
2594
+ }
2595
+ function togglePetPanel(event) {
2596
+ if (petDragged.current && event.detail !== 0) {
2597
+ petDragged.current = false;
2598
+ return;
2599
+ }
2600
+ navigateScreen(
2601
+ event.currentTarget.classList.contains('owc-pet-clock') ? 'income' : 'home',
2602
+ event,
2603
+ );
2604
+ }
2605
+ function closePetMenu(restore = true) {
2606
+ dispatchUI({ type: 'dismiss-menu' });
2607
+ if (restore)
2608
+ (menuTrigger.current?.isConnected
2609
+ ? menuTrigger.current
2610
+ : box.current?.querySelector('.owc-pet-handle')
2611
+ )?.focus();
2612
+ }
2613
+ const liveFeedback = () =>
2614
+ h(
2615
+ 'span',
2616
+ { className: 'owc-sr-only', role: 'status', 'aria-atomic': true },
2617
+ h(
2618
+ 'span',
2619
+ {
2620
+ key: waterPrompt
2621
+ ? waterState.eventId
2622
+ : (waterNotice?.token ?? waterNotice?.key ?? 'idle'),
2623
+ },
2624
+ waterPrompt ? t('waterDue') : waterNotice ? t(waterNotice.key) : '',
2625
+ ),
2626
+ );
2627
+ const petShowing = petOpen;
2628
+ const petFloating = petShowing || waterPrompt;
2629
+ React.useLayoutEffect(() => {
2630
+ if (!petMode || !petFloating || !cardContent.current) return;
2631
+ const content = cardContent.current,
2632
+ header = petPanel.current?.querySelector('.owc-head');
2633
+ let frame = 0;
2634
+ const measure = () => {
2635
+ const height = Math.ceil(
2636
+ content.getBoundingClientRect().height +
2637
+ (header?.getBoundingClientRect().height ?? 0) +
2638
+ 2,
2639
+ );
2640
+ setCardMeasure((current) =>
2641
+ current.key === measureKey && Math.abs(current.height - height) < 1
2642
+ ? current
2643
+ : { key: measureKey, height },
2644
+ );
2645
+ };
2646
+ const observer =
2647
+ typeof ResizeObserver === 'function'
2648
+ ? new ResizeObserver(() => {
2649
+ cancelAnimationFrame(frame);
2650
+ frame = requestAnimationFrame(measure);
2651
+ })
2652
+ : null;
2653
+ observer?.observe(content);
2654
+ if (header) observer?.observe(header);
2655
+ measure();
2656
+ return () => {
2657
+ observer?.disconnect();
2658
+ cancelAnimationFrame(frame);
2659
+ };
2660
+ }, [petMode, petFloating, measureKey, ui.pending]);
2661
+ React.useLayoutEffect(() => {
2662
+ if (!petMode || !ui.menu || !menuContent.current) return;
2663
+ const content = menuContent.current;
2664
+ let frame = 0;
2665
+ const measure = () => {
2666
+ const height = Math.ceil(content.getBoundingClientRect().height + 2);
2667
+ setMenuMeasure((current) =>
2668
+ current.width === menuWidth && Math.abs(current.height - height) < 1
2669
+ ? current
2670
+ : { width: menuWidth, height },
2671
+ );
2672
+ };
2673
+ const observer =
2674
+ typeof ResizeObserver === 'function'
2675
+ ? new ResizeObserver(() => {
2676
+ cancelAnimationFrame(frame);
2677
+ frame = requestAnimationFrame(measure);
2678
+ })
2679
+ : null;
2680
+ observer?.observe(content);
2681
+ measure();
2682
+ return () => {
2683
+ observer?.disconnect();
2684
+ cancelAnimationFrame(frame);
2685
+ };
2686
+ }, [petMode, !!ui.menu, menuWidth]);
2687
+ React.useLayoutEffect(() => {
2688
+ if (!petMode || petShowing || waterPrompt || !waterNotice || !toastContent.current)
2689
+ return;
2690
+ const content = toastContent.current;
2691
+ let frame = 0;
2692
+ const measure = () => {
2693
+ const height = Math.ceil(content.getBoundingClientRect().height + 20);
2694
+ setToastHeight((current) => (current === height ? current : height));
2695
+ };
2696
+ const observer =
2697
+ typeof ResizeObserver === 'function'
2698
+ ? new ResizeObserver(() => {
2699
+ cancelAnimationFrame(frame);
2700
+ frame = requestAnimationFrame(measure);
2701
+ })
2702
+ : null;
2703
+ observer?.observe(content);
2704
+ measure();
2705
+ return () => {
2706
+ observer?.disconnect();
2707
+ cancelAnimationFrame(frame);
2708
+ };
2709
+ }, [petMode, petShowing, waterPrompt, waterNotice, viewport.width]);
2710
+ React.useEffect(() => {
2711
+ if (!ui.menu) return;
2712
+ const blur = () => dispatchUI({ type: 'dismiss-menu' });
2713
+ window.addEventListener('blur', blur);
2714
+ return () => window.removeEventListener('blur', blur);
2715
+ }, [ui.menu]);
2716
+ React.useEffect(() => {
2717
+ if (!petMode || (!petShowing && !ui.menu)) return;
2718
+ const outside = (event) => {
2719
+ if (ui.menu && !menuBox.current?.contains(event.target)) {
2720
+ dispatchUI({ type: 'dismiss-menu' });
2721
+ return;
2722
+ }
2723
+ if (box.current?.contains(event.target)) return;
2724
+ if (ui.menu) dispatchUI({ type: 'dismiss-menu' });
2725
+ else if (!editing && !waterEditing && !waterBusy)
2726
+ dispatchUI({ type: 'navigate', screen: null });
2727
+ };
2728
+ document.addEventListener('pointerdown', outside);
2729
+ return () => document.removeEventListener('pointerdown', outside);
2730
+ }, [petMode, petShowing, ui.menu, editing, waterEditing, waterBusy]);
2731
+ React.useEffect(() => {
2732
+ if (ui.menu) menuBox.current?.querySelector('[role=menuitem]:not(:disabled)')?.focus();
2733
+ }, [ui.menu]);
2734
+ React.useEffect(() => {
2735
+ if (!petMode || editing || waterEditing || ui.menu) return;
2736
+ if (ui.pending) {
2737
+ box.current?.querySelector('[data-keep-editing]')?.focus();
2738
+ return;
2739
+ }
2740
+ if (ui.screen === null) box.current?.querySelector('.owc-pet-handle')?.focus();
2741
+ else petPanel.current?.querySelector('[data-pet-page-title]')?.focus();
2742
+ }, [ui.screen]);
2743
+
2744
+ React.useEffect(() => {
2745
+ if (ui.pending && (busy || waterBusy))
2746
+ box.current?.querySelector('[data-nav-confirm]')?.focus();
2747
+ }, [ui.pending, busy, waterBusy]);
2748
+ const draftGuard = () =>
2749
+ h(
2750
+ 'div',
2751
+ {
2752
+ className: 'owc-pet-unsaved',
2753
+ 'data-nav-confirm': true,
2754
+ tabIndex: -1,
2755
+ onKeyDown: (event) => {
2756
+ if (event.key !== 'Tab') return;
2757
+ const buttons = [
2758
+ ...event.currentTarget.querySelectorAll('button:not(:disabled)'),
2759
+ ];
2760
+ if (!buttons.length) {
2761
+ event.preventDefault();
2762
+ return;
2763
+ }
2764
+ if (event.shiftKey && document.activeElement === buttons[0]) {
2765
+ event.preventDefault();
2766
+ buttons.at(-1).focus();
2767
+ } else if (!event.shiftKey && document.activeElement === buttons.at(-1)) {
2768
+ event.preventDefault();
2769
+ buttons[0].focus();
2770
+ }
2771
+ },
2772
+ role: 'alertdialog',
2773
+ 'aria-label': t('petUnsaved'),
2774
+ },
2775
+ h('h3', null, t('petUnsaved')),
2776
+ h('p', null, t('petUnsavedHint')),
2777
+ h(
2778
+ 'button',
2779
+ {
2780
+ type: 'button',
2781
+ className: 'owc-primary',
2782
+ disabled: busy || waterBusy,
2783
+ onClick: saveAndContinue,
2784
+ },
2785
+ t('petSaveContinue'),
2786
+ ),
2787
+ h(
2788
+ 'button',
2789
+ {
2790
+ type: 'button',
2791
+ disabled: busy || waterBusy,
2792
+ onClick: () => performNavigation(ui.pending.screen, ui.pending.root),
2793
+ },
2794
+ t('petDiscard'),
2795
+ ),
2796
+ h(
2797
+ 'button',
2798
+ {
2799
+ type: 'button',
2800
+ 'data-keep-editing': true,
2801
+ disabled: busy || waterBusy,
2802
+ onClick: () => dispatchUI({ type: 'stay' }),
2803
+ },
2804
+ t('petKeepEditing'),
2805
+ ),
2806
+ );
2807
+ const petPageTitle = {
2808
+ home: 'petWaterCard',
2809
+ income: 'petCountdownCard',
2810
+ earnings: 'petEarningsCard',
2811
+ water: 'petWaterSettings',
2812
+ appearance: 'petAppearance',
2813
+ schedule: 'petWorkSettings',
502
2814
  };
2815
+ const petHeader = () =>
2816
+ h(
2817
+ 'div',
2818
+ { className: 'owc-head' },
2819
+ PET_EDITOR_SCREENS.includes(ui.screen) &&
2820
+ ui.back !== null &&
2821
+ h(
2822
+ 'button',
2823
+ {
2824
+ type: 'button',
2825
+ className: 'owc-text',
2826
+ disabled: busy || waterBusy || !!ui.pending,
2827
+ onClick: () => navigateScreen(ui.back),
2828
+ 'aria-label': t('petBack'),
2829
+ },
2830
+ '← ',
2831
+ t('petBack'),
2832
+ ),
2833
+ h(
2834
+ 'strong',
2835
+ { 'data-pet-page-title': true, tabIndex: -1, style: { flex: 1, fontSize: 12 } },
2836
+ t(petPageTitle[ui.screen] || 'waterTitle'),
2837
+ ),
2838
+ h(
2839
+ 'button',
2840
+ {
2841
+ type: 'button',
2842
+ className: 'owc-icon',
2843
+ 'aria-label': t('petCloseAll'),
2844
+ disabled: busy || waterBusy || !!ui.pending,
2845
+ onClick: closePetPanel,
2846
+ },
2847
+ iconSvg('close', 14),
2848
+ ),
2849
+ );
2850
+ const petHome = () =>
2851
+ h(
2852
+ 'div',
2853
+ { className: 'owc-pet-home', 'data-pet-home': true },
2854
+
2855
+ h(
2856
+ 'div',
2857
+ {
2858
+ className: 'owc-pet-water-card' + (waterPrompt ? ' owc-water-due' : ''),
2859
+ 'data-water-event': waterPrompt ? waterState.eventId : undefined,
2860
+ },
2861
+ h(
2862
+ 'p',
2863
+ { className: 'owc-water-status', 'data-water-status': true },
2864
+ waterPrompt
2865
+ ? t('waterDue')
2866
+ : waterState.phase === 'quiet'
2867
+ ? t('petQuietActive')
2868
+ : waterSummary(),
2869
+ ),
2870
+ h(
2871
+ 'div',
2872
+ { className: 'owc-pet-water-row' },
2873
+ h(
2874
+ 'button',
2875
+ {
2876
+ type: 'button',
2877
+ className: 'owc-primary',
2878
+ disabled: !waterCanRecord || waterBusy,
2879
+ onClick: recordWater,
2880
+ },
2881
+ t('petRecord'),
2882
+ ),
2883
+ h(
2884
+ 'span',
2885
+ { 'data-water-count': true },
2886
+ t('waterCount') + ': ' + waterState.count,
2887
+ ),
2888
+ ),
2889
+ ),
2890
+ h(
2891
+ 'div',
2892
+ { className: 'owc-pet-quiet-row' },
2893
+ [30, 60].map((minutes) =>
2894
+ h(
2895
+ 'button',
2896
+ {
2897
+ type: 'button',
2898
+ key: minutes,
2899
+ disabled: !waterCanQuiet || waterBusy,
2900
+ onClick: () => waterAction({ type: 'quiet', minutes }),
2901
+ },
2902
+ t(minutes === 30 ? 'petQuiet30' : 'petQuiet60'),
2903
+ ),
2904
+ ),
2905
+ ),
2906
+ h(
2907
+ 'div',
2908
+ { className: 'owc-pet-quiet-status' },
2909
+ waterPrompt
2910
+ ? h(
2911
+ 'button',
2912
+ {
2913
+ type: 'button',
2914
+ disabled: waterBusy,
2915
+ onClick: () => waterAction({ type: 'snooze', eventId: waterState.eventId }),
2916
+ },
2917
+ t('waterSnooze'),
2918
+ )
2919
+ : water.shift?.paused
2920
+ ? t('petPausedHint')
2921
+ : waterState.phase === 'quiet'
2922
+ ? h(
2923
+ React.Fragment,
2924
+ null,
2925
+ t('waterQuiet') +
2926
+ ' · ' +
2927
+ formatDuration(waterState.quietRemaining / 1000),
2928
+ h(
2929
+ 'button',
2930
+ {
2931
+ type: 'button',
2932
+ disabled: waterBusy,
2933
+ onClick: () => waterAction({ type: 'end-quiet' }),
2934
+ },
2935
+ t('waterQuietEnd'),
2936
+ ),
2937
+ )
2938
+ : t('petQuietHint'),
2939
+ ),
2940
+ h(
2941
+ 'div',
2942
+ { className: 'owc-pet-feedback' },
2943
+ waterNotice
2944
+ ? t(waterNotice.key)
2945
+ : waterError
2946
+ ? t('waterUnavailable')
2947
+ : t('petHomeHint'),
2948
+ ),
2949
+ waterError &&
2950
+ h(
2951
+ 'button',
2952
+ {
2953
+ type: 'button',
2954
+ disabled: waterBusy,
2955
+ onClick: () => waterAction({ type: 'retry' }),
2956
+ },
2957
+ t('waterRetry'),
2958
+ ),
2959
+ h(
2960
+ 'div',
2961
+ { className: 'owc-pet-home-links' },
2962
+ h('button', { type: 'button', onClick: openWaterEditor }, t('petWaterSettings')),
2963
+ h(
2964
+ 'button',
2965
+ {
2966
+ type: 'button',
2967
+ disabled:
2968
+ !water.enabled ||
2969
+ busy ||
2970
+ waterBusy ||
2971
+ waterError ||
2972
+ !waterContext ||
2973
+ water.shift?.id !== waterContext.id,
2974
+ onClick: () => waterAction({ type: water.shift?.paused ? 'resume' : 'pause' }),
2975
+ },
2976
+ t(water.shift?.paused ? 'waterResume' : 'waterPause'),
2977
+ ),
2978
+ ),
2979
+ );
2980
+ const contextMenu = () =>
2981
+ ui.menu &&
2982
+ h(
2983
+ 'div',
2984
+ {
2985
+ ref: menuBox,
2986
+ className: 'owc-pet-context',
2987
+ role: 'menu',
2988
+ 'aria-label': t('petMenu'),
2989
+ style: {
2990
+ left: menuLayout.left,
2991
+ top: menuLayout.top,
2992
+ width: menuLayout.width,
2993
+ maxHeight: menuLayout.height,
2994
+ },
2995
+ onBlur: (event) => {
2996
+ if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget))
2997
+ dispatchUI({ type: 'dismiss-menu' });
2998
+ },
2999
+ onKeyDown: (event) => {
3000
+ if (event.key === 'Escape') {
3001
+ event.preventDefault();
3002
+ event.stopPropagation();
3003
+ closePetMenu();
3004
+ return;
3005
+ }
3006
+ const items = [
3007
+ ...event.currentTarget.querySelectorAll('[role=menuitem]:not(:disabled)'),
3008
+ ];
3009
+ const index = items.indexOf(document.activeElement);
3010
+ const next =
3011
+ event.key === 'ArrowDown'
3012
+ ? (index + 1) % items.length
3013
+ : event.key === 'ArrowUp'
3014
+ ? (index + items.length - 1) % items.length
3015
+ : event.key === 'Home'
3016
+ ? 0
3017
+ : event.key === 'End'
3018
+ ? items.length - 1
3019
+ : -1;
3020
+ if (next >= 0) {
3021
+ event.preventDefault();
3022
+ event.stopPropagation();
3023
+ items[next]?.focus();
3024
+ }
3025
+ },
3026
+ },
3027
+ h(
3028
+ 'div',
3029
+ { ref: menuContent, className: 'owc-menu-natural' },
3030
+ [
3031
+ ['income', 'petCountdownCard'],
3032
+ ['home', 'petWaterCard'],
3033
+ ['earnings', 'petEarningsCard'],
3034
+ ['appearance', 'petAppearance'],
3035
+ ['schedule', 'petWorkSettings'],
3036
+ ].map(([screen, label]) =>
3037
+ h(
3038
+ 'button',
3039
+ {
3040
+ key: screen,
3041
+ type: 'button',
3042
+ role: 'menuitem',
3043
+ tabIndex: -1,
3044
+ disabled: busy || waterBusy,
3045
+ onClick: (event) => navigateScreen(screen, event),
3046
+ },
3047
+ t(label),
3048
+ ),
3049
+ ),
503
3050
 
504
- const notices = () => h(React.Fragment, null,
505
- error && h('p', { className: 'owc-error', role: 'alert' }, error),
506
- state?.error && h('p', { className: 'owc-error', role: 'alert' }, state.error),
507
- notice && h('p', { className: 'owc-note', role: 'status' }, notice),
508
- storageError && h('p', { className: 'owc-note' }, t('storage')));
509
-
510
- return h('section', {
511
- ref: box,
512
- className: 'owc-v2' + (collapsed ? ' owc-compact' : '') + (editing ? ' owc-editing' : ''),
513
- 'aria-label': t('title'), 'data-version': '1.0.0', 'data-status': safeState?.status || 'setup',
514
- style: cardStyle,
515
- onPointerDown: dragDown, onPointerMove: dragMove, onPointerUp: dragEnd,
516
- onPointerCancel: dragEnd, onLostPointerCapture: dragEnd,
517
- },
518
- h('style', null, STYLE),
519
- h('div', { className: 'owc-glass', 'aria-hidden': 'true' }),
520
- h('div', { className: 'owc-content' },
521
- collapsed ? strip() : h(React.Fragment, null,
522
- panelHead(),
523
- editing ? editor() : panelBody(),
524
- notices())));
3051
+ h('hr', null),
3052
+ h(
3053
+ 'button',
3054
+ {
3055
+ type: 'button',
3056
+ role: 'menuitem',
3057
+ tabIndex: -1,
3058
+ onClick: () => {
3059
+ setPreference({ petPosition: null });
3060
+ closePetMenu();
3061
+ },
3062
+ },
3063
+ t('reset'),
3064
+ ),
3065
+ ),
3066
+ );
3067
+
3068
+ if (petMode)
3069
+ return h(
3070
+ 'section',
3071
+ {
3072
+ ref: box,
3073
+ className: 'owc-v2 owc-pet-shell',
3074
+ 'data-version': '1.1.0',
3075
+ 'data-pet-model': stageModel,
3076
+ 'data-status': safeState.status,
3077
+ 'aria-label': t('companionLabel'),
3078
+ style: {
3079
+ '--owc-alpha': String(view.opacity),
3080
+ left: petGeometry.x,
3081
+ top: petGeometry.y,
3082
+ right: 'auto',
3083
+ width: petGeometry.petWidth,
3084
+ height: petGeometry.petHeight,
3085
+ },
3086
+ onPointerDown: dragDown,
3087
+ onPointerMove: dragMove,
3088
+ onPointerUp: dragEnd,
3089
+ onPointerCancel: dragEnd,
3090
+ onLostPointerCapture: dragEnd,
3091
+ onContextMenuCapture: openAppearanceContext,
3092
+ onKeyDown: (event) => {
3093
+ if (appearanceShortcut(event)) return;
3094
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
3095
+ event.preventDefault();
3096
+ event.stopPropagation();
3097
+ if (ui.menu) closePetMenu();
3098
+ else if (busy || waterBusy) return;
3099
+ else if (ui.pending) dispatchUI({ type: 'stay' });
3100
+ else if (editing || waterEditing) navigateScreen(ui.back);
3101
+ else if (petShowing) closePetPanel();
3102
+ else if (waterPrompt)
3103
+ void waterAction({ type: 'snooze', eventId: waterState.eventId });
3104
+ },
3105
+ },
3106
+ h('style', null, STYLE),
3107
+ liveFeedback(),
3108
+ h(
3109
+ 'div',
3110
+ { className: 'owc-pet-stage' },
3111
+ h(
3112
+ 'button',
3113
+ {
3114
+ type: 'button',
3115
+ className: 'owc-pet-handle',
3116
+ 'aria-label': t('petOpen'),
3117
+ 'aria-expanded': !!petShowing,
3118
+ title: t('companionName') + ' · ' + t('petHint'),
3119
+ onClick: togglePetPanel,
3120
+ onKeyDown: gripKeys,
3121
+ },
3122
+ petArtwork(
3123
+ h,
3124
+ companion,
3125
+ petGeometry.artworkSize,
3126
+ waterNotice?.token,
3127
+ stageModel,
3128
+ PET_SPRITES,
3129
+ ),
3130
+ ),
3131
+ h(
3132
+ 'button',
3133
+ {
3134
+ type: 'button',
3135
+ className: 'owc-pet-clock',
3136
+ onClick: togglePetPanel,
3137
+ 'aria-label': t('petClock'),
3138
+ },
3139
+ iconSvg('alarm', 12),
3140
+ h('span', { className: 'owc-pet-clock-label' }, t(safeState.status)),
3141
+ ['done', 'rest'].includes(safeState.status) ? null : h('strong', null, duration),
3142
+ ),
3143
+ ),
3144
+ waterPrompt &&
3145
+ petShowing &&
3146
+ ui.screen !== 'home' &&
3147
+ h(
3148
+ 'button',
3149
+ {
3150
+ type: 'button',
3151
+ className: 'owc-pet-water-alert owc-water-due',
3152
+ 'data-water-event': waterState.eventId,
3153
+ 'aria-label': t('petWaterAlert'),
3154
+ onClick: (event) => navigateScreen('home', event),
3155
+ style: {
3156
+ left: waterAlertLayout.left,
3157
+ top: waterAlertLayout.top,
3158
+ width: waterAlertLayout.width,
3159
+ height: waterAlertLayout.height,
3160
+ },
3161
+ },
3162
+ iconSvg('water', 13),
3163
+ t('petWaterBadge'),
3164
+ ),
3165
+ contextMenu(),
3166
+ !petShowing &&
3167
+ !waterPrompt &&
3168
+ waterNotice &&
3169
+ h(
3170
+ 'div',
3171
+ {
3172
+ className: 'owc-pet-toast',
3173
+ 'aria-hidden': true,
3174
+ style: {
3175
+ left: toastLayout.left,
3176
+ top: toastLayout.top,
3177
+ width: toastLayout.width,
3178
+ maxHeight: toastLayout.height,
3179
+ },
3180
+ },
3181
+ h('div', { ref: toastContent }, t(waterNotice.key)),
3182
+ ),
3183
+ petFloating &&
3184
+ h(
3185
+ 'div',
3186
+ {
3187
+ ref: petPanel,
3188
+ className: 'owc-pet-panel owc-pet-workspace',
3189
+ 'data-pet-panel': petShowing ? 'open' : 'bubble',
3190
+ 'data-pet-screen': ui.screen || 'reminder',
3191
+ 'data-side': floatingPanel.side,
3192
+ role: 'region',
3193
+ 'aria-label': t(petPageTitle[ui.screen] || 'waterTitle'),
3194
+ style: {
3195
+ left: floatingPanel.left,
3196
+ top: floatingPanel.top,
3197
+ width: floatingPanel.width,
3198
+ height: floatingPanel.height,
3199
+ maxHeight: floatingPanel.maxHeight,
3200
+ },
3201
+ },
3202
+ h('div', { className: 'owc-glass', 'aria-hidden': true }),
3203
+ petShowing
3204
+ ? petHeader()
3205
+ : h(
3206
+ 'div',
3207
+ { className: 'owc-head' },
3208
+ h('strong', { style: { flex: 1, fontSize: 12 } }, t('waterTitle')),
3209
+ h(
3210
+ 'button',
3211
+ {
3212
+ type: 'button',
3213
+ className: 'owc-icon',
3214
+ 'aria-label': t('waterSnooze'),
3215
+ disabled: waterBusy,
3216
+ onClick: () =>
3217
+ waterAction({ type: 'snooze', eventId: waterState.eventId }),
3218
+ },
3219
+ iconSvg('close', 14),
3220
+ ),
3221
+ ),
3222
+ h(
3223
+ 'div',
3224
+ { className: 'owc-pet-scroll' },
3225
+ h(
3226
+ 'div',
3227
+ { ref: cardContent, className: 'owc-pet-natural' },
3228
+ petShowing
3229
+ ? h(
3230
+ React.Fragment,
3231
+ null,
3232
+ ui.pending
3233
+ ? draftGuard()
3234
+ : waterEditing
3235
+ ? waterSettings()
3236
+ : editing
3237
+ ? editor()
3238
+ : petDetails
3239
+ ? panelBody('clock')
3240
+ : ui.screen === 'earnings'
3241
+ ? panelBody('earnings')
3242
+ : petHome(),
3243
+ notices(),
3244
+ )
3245
+ : waterReminder(),
3246
+ ),
3247
+ ),
3248
+ ),
3249
+ );
3250
+
3251
+ return h(
3252
+ 'section',
3253
+ {
3254
+ ref: box,
3255
+ className:
3256
+ 'owc-v2' +
3257
+ (collapsed ? ' owc-compact' : '') +
3258
+ (collapsed && waterQuickOpen ? ' owc-quick' : '') +
3259
+ (editing || waterEditing ? ' owc-editing' : ''),
3260
+ 'aria-label': t('title'),
3261
+ 'data-version': '1.1.0',
3262
+ 'data-status': safeState?.status || 'setup',
3263
+ style: cardStyle,
3264
+ onPointerDown: dragDown,
3265
+ onPointerMove: dragMove,
3266
+ onPointerUp: dragEnd,
3267
+ onPointerCancel: dragEnd,
3268
+ onLostPointerCapture: dragEnd,
3269
+ onContextMenuCapture: openAppearanceContext,
3270
+ onKeyDown: (event) => {
3271
+ if (
3272
+ appearanceShortcut(event) ||
3273
+ event.key !== 'Escape' ||
3274
+ event.defaultPrevented ||
3275
+ busy ||
3276
+ waterBusy
3277
+ )
3278
+ return;
3279
+ event.preventDefault();
3280
+ if (ui.pending) dispatchUI({ type: 'stay' });
3281
+ else if (editing || waterEditing) navigateScreen(ui.back);
3282
+ else if (waterQuickOpen) closeWaterQuick();
3283
+ else setPreference({ mini: true });
3284
+ },
3285
+ },
3286
+ h('style', null, STYLE),
3287
+ h('div', { className: 'owc-glass', 'aria-hidden': 'true' }),
3288
+ h(
3289
+ 'div',
3290
+ { className: 'owc-content' },
3291
+ h(
3292
+ 'span',
3293
+ { className: 'owc-sr-only', role: 'status', 'aria-atomic': true },
3294
+ h(
3295
+ 'span',
3296
+ {
3297
+ key: waterPrompt
3298
+ ? waterState.eventId
3299
+ : (waterNotice?.token ?? waterNotice?.key ?? 'idle'),
3300
+ },
3301
+ waterPrompt ? t('waterDue') : waterNotice ? t(waterNotice.key) : '',
3302
+ ),
3303
+ ),
3304
+ collapsed
3305
+ ? h(
3306
+ React.Fragment,
3307
+ null,
3308
+ strip(),
3309
+ waterQuickOpen && waterOverview(true),
3310
+ !waterQuickOpen &&
3311
+ waterNotice &&
3312
+ (!view.companion || companion.announce) &&
3313
+ h('p', { className: 'owc-mini-feedback' }, t(waterNotice.key)),
3314
+ )
3315
+ : h(
3316
+ React.Fragment,
3317
+ null,
3318
+ panelHead(),
3319
+ !editing && !waterEditing && companionCard(),
3320
+ ui.pending
3321
+ ? draftGuard()
3322
+ : waterEditing
3323
+ ? waterSettings()
3324
+ : editing
3325
+ ? editor()
3326
+ : panelBody(),
3327
+ notices(),
3328
+ !editing && !waterEditing && waterOverview(),
3329
+ ),
3330
+ !collapsed && waterReminder(),
3331
+ ),
3332
+ );
525
3333
  }
526
3334
 
527
- ctx.slots.inject('shell.overlay', () => ctx.slots.register({
528
- name: 'shell.overlay', id: 'offwork-clock', order: 900,
529
- label: () => ctx.locale.bind('offwork-clock')('title'),
530
- }, Clock));
3335
+ ctx.slots.inject('shell.overlay', () =>
3336
+ ctx.slots.register(
3337
+ {
3338
+ name: 'shell.overlay',
3339
+ id: 'offwork-clock',
3340
+ order: 900,
3341
+ label: () => ctx.locale.bind('offwork-clock')('title'),
3342
+ },
3343
+ Clock,
3344
+ ),
3345
+ );
531
3346
  },
532
3347
  };
533
3348
  },