dsh-offwork 1.0.1 → 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,8 +5,13 @@ 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: {
11
+ ...COMPANION_MESSAGES.zh,
12
+ ...PET_MESSAGES.zh,
13
+ ...PET_INTERACTION_MESSAGES.zh,
14
+ ...PET_SURFACE_MESSAGES.zh,
10
15
  title: '下班计时器',
11
16
  working: '距离下班',
12
17
  before: '距离开工',
@@ -53,6 +58,7 @@ window.__ModuleLoader__.load({
53
58
  salaryGroup: '薪资',
54
59
  scheduleGroup: '作息',
55
60
  appearanceGroup: '外观',
61
+ appearanceDone: '完成',
56
62
  advanced: '高级设置',
57
63
  sessionHint: '仅用于记录配置读写操作,不是账本,不影响工资计算。',
58
64
  session: '操作记录会话',
@@ -65,8 +71,68 @@ window.__ModuleLoader__.load({
65
71
  shiftDate: '班次',
66
72
  opacity: '背景透明度',
67
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: '作息已变化,请点击“读取最新配置”后继续提醒。',
68
130
  },
69
131
  en: {
132
+ ...COMPANION_MESSAGES.en,
133
+ ...PET_MESSAGES.en,
134
+ ...PET_INTERACTION_MESSAGES.en,
135
+ ...PET_SURFACE_MESSAGES.en,
70
136
  title: 'Offwork Clock',
71
137
  working: 'Until clock-out',
72
138
  before: 'Until work starts',
@@ -113,6 +179,7 @@ window.__ModuleLoader__.load({
113
179
  salaryGroup: 'Salary',
114
180
  scheduleGroup: 'Schedule',
115
181
  appearanceGroup: 'Appearance',
182
+ appearanceDone: 'Done',
116
183
  advanced: 'Advanced',
117
184
  sessionHint:
118
185
  'Logs configuration operations only; not a ledger and does not affect pay calculations.',
@@ -126,17 +193,79 @@ window.__ModuleLoader__.load({
126
193
  shiftDate: 'Shift',
127
194
  opacity: 'Surface opacity',
128
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.',
129
255
  },
130
256
  };
131
- const STYLE = `
257
+ const STYLE =
258
+ `
132
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}
133
260
  .owc-v2:active{cursor:grabbing}
134
261
  .owc-v2.owc-compact{touch-action:none;border-radius:18px}
262
+ .owc-v2.owc-compact.owc-quick{width:296px;touch-action:manipulation}
135
263
  .owc-v2:not(.owc-compact){width:296px}
136
264
  .owc-v2 :is(button,input,select,label,a){cursor:pointer;user-select:auto;-webkit-user-select:auto}
137
265
  .owc-v2 :is(input,select){user-select:text;-webkit-user-select:text}
138
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}
139
- .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}
140
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}
141
270
  .owc-v2 button:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 7%,transparent)}
142
271
  .owc-v2 button:focus-visible,.owc-v2 :is(input,select):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
@@ -212,9 +341,48 @@ window.__ModuleLoader__.load({
212
341
  @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.owc-glass{opacity:1}}
213
342
  @media(prefers-reduced-transparency:reduce),(prefers-contrast:more){.owc-glass{opacity:1;-webkit-backdrop-filter:none;backdrop-filter:none}}
214
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}
215
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}}
216
- `;
377
+ ` +
378
+ COMPANION_STYLE +
379
+ PET_STYLE +
380
+ PET_INTERACTION_STYLE +
381
+ PET_SURFACE_STYLE;
217
382
  const ICONS = {
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
+ ],
218
386
  eye: [
219
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' }],
220
388
  ['circle', { cx: 8, cy: 8, r: 2.05 }],
@@ -264,7 +432,8 @@ window.__ModuleLoader__.load({
264
432
  apply(ctx) {
265
433
  for (const [lang, dict] of Object.entries(messages))
266
434
  ctx.effect(() => ctx.locale.register('offwork-clock', lang, dict));
267
- const viewKey = 'dsh.offwork-clock.view.v4';
435
+ const viewKey = 'dsh.offwork-clock.view.v5';
436
+ const previousViewKey = 'dsh.offwork-clock.view.v4';
268
437
  const legacyKey = 'dsh.offwork-clock.view.v3';
269
438
 
270
439
  function Clock(props) {
@@ -279,27 +448,152 @@ window.__ModuleLoader__.load({
279
448
  const sessionId = sessionIds.includes(sessionChoice) ? sessionChoice : sessionIds[0];
280
449
  const [data, setData] = React.useState(null);
281
450
  const [draft, setDraft] = React.useState(null);
282
- 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);
283
462
  const [busy, setBusy] = React.useState(false);
284
463
  const [error, setError] = React.useState('');
285
464
  const [notice, setNotice] = React.useState('');
286
465
  const [storageError, setStorageError] = React.useState(false);
287
466
  const [now, setNow] = React.useState(Date.now());
288
- const [view, setView] = React.useState(normalizeView(null));
467
+ const [savedView, setView] = React.useState(normalizeView(null));
468
+ const view =
469
+ appearanceOnly && appearanceDraft ? { ...savedView, ...appearanceDraft } : savedView;
289
470
  const [ready, setReady] = React.useState(false);
290
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));
291
485
  const dragState = React.useRef(null);
292
486
  const lastPosition = React.useRef(null);
293
487
  const generation = React.useRef(0);
294
488
  const requestBusy = React.useRef(false);
295
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
+ };
296
519
 
297
520
  const settings = data?.settings;
298
521
  const workingDraft = draft || settings;
299
522
  const state = settings ? safeCalculate(settings) : null;
300
523
  const preview = workingDraft ? safeCalculate(workingDraft) : null;
301
524
  const safeState = state && !state.error ? state : null;
302
- const collapsed = !!safeState && !editing && view.mini && !error && !state?.error;
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;
303
597
  const seconds = safeState
304
598
  ? safeState.status === 'before'
305
599
  ? safeState.startRemaining
@@ -318,10 +612,23 @@ window.__ModuleLoader__.load({
318
612
  );
319
613
  const money = (value) => (view.masked ? '••••' : formatMoney.format(value));
320
614
  const moneyText = safeState ? money(safeState.earned) : '—';
321
- const cardStyle = Object.assign(
322
- { '--owc-alpha': String(view.opacity) },
323
- 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,
324
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
+ };
325
632
 
326
633
  function safeCalculate(value) {
327
634
  try {
@@ -339,20 +646,16 @@ window.__ModuleLoader__.load({
339
646
 
340
647
  const clamp = React.useCallback(
341
648
  (position) =>
342
- clampPosition(
343
- position,
344
- { width: window.innerWidth, height: window.innerHeight },
345
- {
346
- width: box.current?.offsetWidth || 300,
347
- height: box.current?.offsetHeight || 200,
348
- },
349
- ),
649
+ clampPosition(position, petViewport(window), {
650
+ width: box.current?.offsetWidth || 300,
651
+ height: box.current?.offsetHeight || 200,
652
+ }),
350
653
  [],
351
654
  );
352
655
 
353
656
  React.useEffect(() => {
354
657
  try {
355
- const saved = localStorage.getItem(viewKey);
658
+ const saved = localStorage.getItem(viewKey) ?? localStorage.getItem(previousViewKey);
356
659
  const legacy = !saved ? JSON.parse(localStorage.getItem(legacyKey) || 'null') : null;
357
660
  setView(
358
661
  normalizeView(
@@ -378,11 +681,310 @@ window.__ModuleLoader__.load({
378
681
  React.useEffect(() => {
379
682
  if (!ready) return;
380
683
  try {
381
- localStorage.setItem(viewKey, JSON.stringify(normalizeView(view)));
684
+ localStorage.setItem(viewKey, JSON.stringify(normalizeView(savedView)));
382
685
  } catch {
383
686
  setStorageError(true);
384
687
  }
385
- }, [ready, view]);
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
+ }
386
988
 
387
989
  React.useEffect(() => {
388
990
  if (sessionId) reload();
@@ -411,27 +1013,61 @@ window.__ModuleLoader__.load({
411
1013
  };
412
1014
  }, []);
413
1015
 
414
- React.useEffect(() => {
415
- const resize = () =>
416
- setView((current) =>
417
- 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,
418
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
+ };
419
1032
  const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
420
1033
  if (box.current) ro?.observe(box.current);
1034
+ resize();
421
1035
  window.addEventListener('resize', resize);
1036
+ window.visualViewport?.addEventListener('resize', resize);
1037
+ window.visualViewport?.addEventListener('scroll', resize);
422
1038
  return () => {
423
1039
  ro?.disconnect();
424
1040
  window.removeEventListener('resize', resize);
1041
+ window.visualViewport?.removeEventListener('resize', resize);
1042
+ window.visualViewport?.removeEventListener('scroll', resize);
425
1043
  };
426
- }, [clamp]);
1044
+ }, [clamp, petMode]);
427
1045
 
428
1046
  React.useEffect(() => {
429
- if (!editing) {
430
- focusReturn.current?.focus?.();
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();
431
1057
  return;
432
1058
  }
433
- box.current?.querySelector('form input,form select')?.focus?.();
434
- }, [editing]);
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]);
435
1071
 
436
1072
  React.useEffect(
437
1073
  () => () => {
@@ -457,12 +1093,22 @@ window.__ModuleLoader__.load({
457
1093
  setBusy(true);
458
1094
  setError('');
459
1095
  try {
1096
+ const baseSchedule = (await waterRuntime.current?.update({ type: 'sync' }))?.state
1097
+ ?.shift?.schedule;
1098
+ if (token !== generation.current) return;
460
1099
  const next = await request('get');
461
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
+ };
462
1107
  setData(next);
463
1108
  if (!next.configured) {
464
1109
  setDraft({ ...next.settings });
465
- setEditing(true);
1110
+ dispatchUI({ type: 'navigate', screen: 'schedule', discard: true });
1111
+ salaryBase.current = next.settings;
466
1112
  }
467
1113
  if (explicit) setNotice(t('synced'));
468
1114
  } catch (e) {
@@ -480,12 +1126,24 @@ window.__ModuleLoader__.load({
480
1126
  setError('');
481
1127
  const token = ++generation.current;
482
1128
  try {
483
- 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
+ );
484
1136
  if (token === generation.current) {
1137
+ waterScheduleChange.current = {
1138
+ baseSchedule,
1139
+ schedule: hydrationContext(next.settings, calculate(next.settings, Date.now()))
1140
+ .schedule,
1141
+ };
485
1142
  setData(next);
486
- setEditing(false);
1143
+ dispatchUI({ type: 'navigate', screen: ui.back, discard: true });
487
1144
  setDraft(null);
488
1145
  setNotice(t('saved'));
1146
+ return next;
489
1147
  }
490
1148
  } catch (e) {
491
1149
  if (token === generation.current) setError(errorText(e));
@@ -497,15 +1155,22 @@ window.__ModuleLoader__.load({
497
1155
 
498
1156
  function commitPosition(position) {
499
1157
  const next = clamp(position);
500
- setPreference({ position: next });
1158
+ setPreference(petMode ? { petPosition: next } : { position: next });
501
1159
  lastPosition.current = next;
502
1160
  }
503
1161
  function dragDown(e) {
504
1162
  if (e.button !== 0) return;
1163
+ petDragged.current = false;
505
1164
  if (
506
- e.target.closest?.('button,input,select,label,a,textarea,summary,[contenteditable]')
1165
+ petMode
1166
+ ? !e.target.closest?.('.owc-pet-handle')
1167
+ : e.target.closest?.(
1168
+ 'button,input,select,label,a,textarea,summary,[contenteditable]',
1169
+ )
507
1170
  )
508
1171
  return;
1172
+ const capture = petMode ? e.target.closest('.owc-pet-handle') : e.currentTarget;
1173
+ if (petMode && ui.menu) dispatchUI({ type: 'dismiss-menu' });
509
1174
  const rect = box.current.getBoundingClientRect();
510
1175
  dragState.current = {
511
1176
  dx: e.clientX - rect.left,
@@ -513,17 +1178,20 @@ window.__ModuleLoader__.load({
513
1178
  x0: e.clientX,
514
1179
  y0: e.clientY,
515
1180
  moved: false,
1181
+ capture,
516
1182
  };
517
1183
  try {
518
- e.currentTarget.setPointerCapture?.(e.pointerId);
1184
+ capture.setPointerCapture?.(e.pointerId);
519
1185
  } catch {}
520
1186
  }
521
1187
  function dragMove(e) {
522
1188
  const d = dragState.current;
523
1189
  if (!d) return;
524
- 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;
525
1191
  d.moved = true;
1192
+ petDragged.current = true;
526
1193
  const next = clamp({ x: e.clientX - d.dx, y: e.clientY - d.dy });
1194
+ if (petMode) setDragPosition(next);
527
1195
  lastPosition.current = next;
528
1196
  if (box.current) {
529
1197
  box.current.style.left = next.x + 'px';
@@ -535,16 +1203,19 @@ window.__ModuleLoader__.load({
535
1203
  const d = dragState.current;
536
1204
  if (!d) return;
537
1205
  dragState.current = null;
1206
+ if (e.type === 'pointercancel' || e.type === 'lostpointercapture')
1207
+ petDragged.current = false;
538
1208
  if (d.moved && lastPosition.current) commitPosition(lastPosition.current);
1209
+ setDragPosition(null);
539
1210
  try {
540
- if (e.currentTarget.hasPointerCapture?.(e.pointerId))
541
- e.currentTarget.releasePointerCapture(e.pointerId);
1211
+ if (d.capture?.hasPointerCapture?.(e.pointerId))
1212
+ d.capture.releasePointerCapture(e.pointerId);
542
1213
  } catch {}
543
1214
  }
544
1215
  function gripKeys(e) {
545
1216
  if (e.key === 'Home') {
546
1217
  e.preventDefault();
547
- setPreference({ position: null });
1218
+ setPreference(petMode ? { petPosition: null } : { position: null });
548
1219
  return;
549
1220
  }
550
1221
  if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
@@ -556,18 +1227,131 @@ window.__ModuleLoader__.load({
556
1227
  });
557
1228
  }
558
1229
 
559
- function openEditor(e) {
560
- focusReturn.current = e.currentTarget;
561
- setDraft({ ...settings });
562
- setEditing(true);
563
- setError('');
564
- 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);
565
1351
  }
566
1352
  function closeEditor() {
567
- if (!data?.configured) return;
568
- setEditing(false);
569
- setDraft(null);
570
- setError('');
1353
+ if ((!data?.configured && !appearanceOnly) || busy) return;
1354
+ performNavigation(ui.back);
571
1355
  }
572
1356
  function update(key, value) {
573
1357
  setDraft((current) => ({ ...current, [key]: value }));
@@ -644,194 +1428,774 @@ window.__ModuleLoader__.load({
644
1428
  t(view.masked ? 'show' : 'hide'),
645
1429
  );
646
1430
 
647
- const strip = () =>
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,
1474
+ });
1475
+ const companionCard = (compact = false) =>
1476
+ companion.visible &&
648
1477
  h(
649
1478
  'div',
650
- { className: 'owc-pill' },
1479
+ { className: 'owc-companion', 'data-companion': companion.pose },
1480
+ !compact && companionArtwork(h, companion, 52, waterNotice?.token),
651
1481
  h(
652
1482
  'div',
653
- { className: 'owc-pill-top' },
654
- iconSvg('alarm', 16),
1483
+ { className: 'owc-companion-copy' },
655
1484
  h(
656
- 'span',
657
- { className: 'owc-grip', tabIndex: 0, title: t('drag'), onKeyDown: gripKeys },
658
- safeState.status !== 'done' &&
659
- h('span', { className: 'owc-pill-status' }, t(safeState.status)),
660
- h('span', { className: 'owc-pill-time' }, duration),
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'),
661
1558
  ),
1559
+ waterState.phase === 'quiet' &&
662
1560
  h(
663
1561
  'button',
664
1562
  {
665
1563
  type: 'button',
666
- className: 'owc-icon',
667
- style: { marginLeft: 'auto' },
668
- title: t('expand'),
669
- 'aria-label': t('expand'),
670
- onClick: () => setPreference({ mini: false }),
1564
+ disabled: waterBusy || waterError,
1565
+ onClick: () => waterAction({ type: 'end-quiet' }),
671
1566
  },
672
- iconSvg('chevronUp', 14),
1567
+ t('waterQuietEnd'),
673
1568
  ),
674
- ),
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
+ },
675
1647
  h(
676
1648
  'div',
677
- { className: 'owc-pill-bottom' },
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
+ ),
678
1657
  h(
679
- 'span',
1658
+ 'button',
680
1659
  {
681
- className: 'owc-pill-money',
682
- 'aria-label': view.masked ? t('masked') : moneyText,
1660
+ type: 'button',
1661
+ className: 'owc-text',
1662
+ style: { marginLeft: 'auto' },
1663
+ 'aria-label': t('waterEdit'),
1664
+ disabled: !waterReady || !data?.configured,
1665
+ onClick: openWaterEditor,
683
1666
  },
684
- moneyText,
1667
+ t('settings'),
685
1668
  ),
686
- h(
687
- 'div',
688
- { className: 'owc-pill-actions' },
1669
+ quick &&
689
1670
  h(
690
1671
  'button',
691
1672
  {
692
1673
  type: 'button',
693
1674
  className: 'owc-icon',
694
- title: t(view.masked ? 'show' : 'hide'),
695
- 'aria-label': t(view.masked ? 'show' : 'hide'),
696
- onClick: () => setPreference({ masked: !view.masked }),
1675
+ 'aria-label': t('waterQuickClose'),
1676
+ title: t('waterQuickClose'),
1677
+ onClick: closeWaterQuick,
697
1678
  },
698
- iconSvg(view.masked ? 'eyeOff' : 'eye', 14),
1679
+ iconSvg('close', 14),
699
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'),
700
1696
  ),
1697
+ h('span', { 'data-water-count': true }, t('waterCount') + ': ' + waterState.count),
701
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)),
702
1705
  );
703
1706
 
704
- const panelHead = () =>
1707
+ const waterSettings = () =>
705
1708
  h(
706
- 'div',
707
- { className: 'owc-head' },
708
- h(
709
- 'span',
710
- {
711
- className: 'owc-title',
712
- tabIndex: 0,
713
- title: t('drag'),
714
- onKeyDown: gripKeys,
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
+ }
715
1720
  },
716
- editing ? t(data?.configured ? 'settings' : 'setup') : t('title'),
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
+ }),
717
1735
  ),
718
- editing
719
- ? data?.configured &&
720
- h(
721
- 'button',
722
- {
723
- type: 'button',
724
- className: 'owc-icon',
725
- title: t('cancel'),
726
- 'aria-label': t('cancel'),
727
- onClick: closeEditor,
728
- disabled: busy,
729
- },
730
- iconSvg('close'),
731
- )
732
- : h(
733
- React.Fragment,
734
- null,
735
- h(
736
- 'button',
737
- {
738
- type: 'button',
739
- className: 'owc-icon',
740
- title: t('settings'),
741
- 'aria-label': t('settings'),
742
- onClick: openEditor,
743
- disabled: busy,
744
- },
745
- iconSvg('sliders'),
746
- ),
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) =>
747
1744
  h(
748
1745
  'button',
749
1746
  {
750
1747
  type: 'button',
751
- className: 'owc-icon',
752
- title: t('collapse'),
753
- 'aria-label': t('collapse'),
754
- onClick: () => setPreference({ mini: true }),
1748
+ key: minutes,
1749
+ 'aria-pressed': Number(waterInterval) === minutes,
1750
+ onClick: () => {
1751
+ setWaterInterval(String(minutes));
1752
+ setWaterInvalid(false);
1753
+ },
755
1754
  },
756
- iconSvg('chevronDown'),
1755
+ minutes + ' ' + t('waterMinutes'),
757
1756
  ),
758
1757
  ),
759
- );
760
-
761
- const panelBody = () => {
762
- if (!safeState)
763
- return h(
764
- 'div',
765
- { className: 'owc-main' },
766
- h(
767
- 'div',
768
- { className: 'owc-status' },
769
- h('span', { className: 'owc-dot' }),
770
- t('loading'),
771
1758
  ),
772
- !sessionId && h('p', { className: 'owc-hint' }, t('noSession')),
773
1759
  h(
774
- 'div',
775
- { className: 'owc-row-actions' },
776
- h(
777
- 'button',
778
- {
779
- type: 'button',
780
- className: 'owc-text',
781
- onClick: () => reload(true),
782
- disabled: busy || !sessionId,
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);
783
1775
  },
784
- iconSvg('refresh', 12),
785
- t('retry'),
786
- ),
1776
+ }),
787
1777
  ),
788
- );
789
- const status = safeState.status === 'done' ? '' : t(safeState.status);
790
- return h(
791
- 'div',
792
- { className: 'owc-main' },
793
- status &&
794
- h('div', { className: 'owc-status' }, h('span', { className: 'owc-dot' }), status),
795
- h('div', { className: 'owc-time', 'aria-label': status + ' ' + duration }, duration),
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')),
796
1789
  h(
797
1790
  'div',
798
- {
799
- className: 'owc-progress',
800
- role: 'progressbar',
801
- 'aria-label': t('progress'),
802
- 'aria-valuenow': Math.floor(safeState.progress * 100),
803
- 'aria-valuemin': 0,
804
- 'aria-valuemax': 100,
805
- },
806
- h('div', { style: { width: safeState.progress * 100 + '%' } }),
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
+ ),
807
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')),
808
1876
  h(
809
1877
  'div',
810
- { className: 'owc-earn-row' },
811
- h('span', { className: 'owc-muted' }, t('earned')),
812
- salaryToggle(),
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
+ ),
813
1897
  ),
1898
+ );
1899
+
1900
+ const strip = () =>
1901
+ h(
1902
+ 'div',
1903
+ { className: 'owc-pill' },
814
1904
  h(
815
1905
  'div',
816
- { className: 'owc-money', 'aria-label': view.masked ? t('masked') : moneyText },
817
- moneyText,
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
+ ),
818
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' },
819
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(
820
2118
  'div',
821
- { className: 'owc-stats' },
2119
+ { className: 'owc-main' },
822
2120
  h(
823
2121
  'div',
824
- null,
825
- h('span', null, t('daily')),
826
- h('strong', null, money(safeState.daily)),
2122
+ { className: 'owc-status' },
2123
+ h('span', { className: 'owc-dot' }),
2124
+ t('loading'),
827
2125
  ),
2126
+ !sessionId && h('p', { className: 'owc-hint' }, t('noSession')),
828
2127
  h(
829
2128
  'div',
830
- null,
831
- h('span', null, t('hourly')),
832
- h('strong', null, money(safeState.hourly)),
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
+ );
2143
+ const status = safeState.status === 'done' ? '' : t(safeState.status);
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
+ ),
833
2198
  ),
834
- ),
835
2199
  h(
836
2200
  'div',
837
2201
  { className: 'owc-meta' },
@@ -867,20 +2231,140 @@ window.__ModuleLoader__.load({
867
2231
  iconSvg('refresh', 12),
868
2232
  t('refresh'),
869
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
+ );
2253
+ };
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'),
870
2284
  h(
871
- 'button',
2285
+ 'select',
872
2286
  {
873
- type: 'button',
874
- className: 'owc-text',
875
- onClick: () => setPreference({ position: null }),
2287
+ 'aria-label': t('petCharacter'),
2288
+ value: view.petModel,
2289
+ disabled: !view.companion,
2290
+ onChange: (event) => updateAppearance({ petModel: event.target.value }),
876
2291
  },
877
- t('reset'),
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
+ ),
878
2333
  ),
879
2334
  ),
880
2335
  );
881
- };
882
-
883
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
+ );
884
2368
  if (!workingDraft) return null;
885
2369
  const payDaily = workingDraft.payMode === 'daily';
886
2370
  const salaryKey = payDaily ? 'dailySalary' : 'monthlySalary';
@@ -932,7 +2416,7 @@ window.__ModuleLoader__.load({
932
2416
  onKeyDown: (e) => {
933
2417
  if (e.key === 'Escape') {
934
2418
  e.preventDefault();
935
- closeEditor();
2419
+ navigateScreen(ui.back);
936
2420
  }
937
2421
  },
938
2422
  },
@@ -1048,34 +2532,6 @@ window.__ModuleLoader__.load({
1048
2532
  ),
1049
2533
  ),
1050
2534
  ),
1051
- h(
1052
- 'fieldset',
1053
- { className: 'owc-setting-group' },
1054
- h('legend', null, t('appearanceGroup')),
1055
- label(
1056
- t('opacity'),
1057
- h(
1058
- React.Fragment,
1059
- null,
1060
- h('input', {
1061
- className: 'owc-range',
1062
- type: 'range',
1063
- min: '30',
1064
- max: '100',
1065
- step: '1',
1066
- value: String(Math.round(view.opacity * 100)),
1067
- disabled: busy,
1068
- 'aria-label': t('opacity'),
1069
- onChange: (e) => setPreference({ opacity: e.target.valueAsNumber / 100 }),
1070
- }),
1071
- h(
1072
- 'span',
1073
- { className: 'owc-hint', style: { margin: 0 } },
1074
- Math.round(view.opacity * 100) + '% · ' + t('opacityHint'),
1075
- ),
1076
- ),
1077
- ),
1078
- ),
1079
2535
  preview &&
1080
2536
  !preview.error &&
1081
2537
  h(
@@ -1133,14 +2589,676 @@ window.__ModuleLoader__.load({
1133
2589
  storageError && h('p', { className: 'owc-note' }, t('storage')),
1134
2590
  );
1135
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',
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
+ ),
3050
+
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
+
1136
3251
  return h(
1137
3252
  'section',
1138
3253
  {
1139
3254
  ref: box,
1140
3255
  className:
1141
- 'owc-v2' + (collapsed ? ' owc-compact' : '') + (editing ? ' owc-editing' : ''),
3256
+ 'owc-v2' +
3257
+ (collapsed ? ' owc-compact' : '') +
3258
+ (collapsed && waterQuickOpen ? ' owc-quick' : '') +
3259
+ (editing || waterEditing ? ' owc-editing' : ''),
1142
3260
  'aria-label': t('title'),
1143
- 'data-version': '1.0.1',
3261
+ 'data-version': '1.1.0',
1144
3262
  'data-status': safeState?.status || 'setup',
1145
3263
  style: cardStyle,
1146
3264
  onPointerDown: dragDown,
@@ -1148,15 +3266,68 @@ window.__ModuleLoader__.load({
1148
3266
  onPointerUp: dragEnd,
1149
3267
  onPointerCancel: dragEnd,
1150
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
+ },
1151
3285
  },
1152
3286
  h('style', null, STYLE),
1153
3287
  h('div', { className: 'owc-glass', 'aria-hidden': 'true' }),
1154
3288
  h(
1155
3289
  'div',
1156
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
+ ),
1157
3304
  collapsed
1158
- ? strip()
1159
- : h(React.Fragment, null, panelHead(), editing ? editor() : panelBody(), notices()),
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(),
1160
3331
  ),
1161
3332
  );
1162
3333
  }