dsh-offwork 1.1.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +48 -26
  3. package/ROADMAP.md +70 -0
  4. package/client.js +5066 -1099
  5. package/client.source.js +1988 -541
  6. package/companion-app.js +1431 -0
  7. package/companion-image.js +59 -0
  8. package/companion-state.js +424 -0
  9. package/companion.js +6 -9
  10. package/countdown.js +83 -0
  11. package/docs/RELEASE-1.2.2.md +20 -0
  12. package/hydration-store.js +55 -16
  13. package/hydration.js +231 -250
  14. package/icon.svg +28 -1
  15. package/index.js +59 -6
  16. package/locale/en.json +1 -1
  17. package/locale/zh.json +1 -1
  18. package/media-manifest.js +29 -0
  19. package/media-server.js +123 -0
  20. package/package.json +13 -3
  21. package/pet-interactions.js +121 -17
  22. package/pet-motion.js +2 -1
  23. package/pet-reminder.js +20 -0
  24. package/pet-surfaces.js +130 -6
  25. package/pet-video.js +221 -72
  26. package/pet-view.js +71 -4
  27. package/settings-api.js +126 -0
  28. package/tests/classic-overview.test.mjs +77 -0
  29. package/tests/companion-state.test.mjs +267 -0
  30. package/tests/companion.test.mjs +12 -12
  31. package/tests/countdown.test.mjs +115 -0
  32. package/tests/host.test.mjs +26 -13
  33. package/tests/hydration-extras.test.mjs +168 -204
  34. package/tests/hydration-store.test.mjs +199 -213
  35. package/tests/hydration.test.mjs +116 -186
  36. package/tests/media-server.test.mjs +179 -0
  37. package/tests/pet-interactions.test.mjs +19 -0
  38. package/tests/pet-reminder.test.mjs +71 -0
  39. package/tests/pet-video.test.mjs +171 -5
  40. package/tests/settings-api.test.mjs +130 -0
  41. package/tests/ui.test.mjs +4 -1
  42. package/tools/build.mjs +45 -16
  43. package/tools/check-package.mjs +12 -0
  44. package/ui-utils.js +44 -0
package/pet-video.js CHANGED
@@ -1,13 +1,47 @@
1
- // Independent transparent-video playback. Presentation only; never records an action.
1
+ // Independent media presentation; never writes settings or hydration records.
2
2
  const PET_VIDEO_SUPPORT = new WeakMap();
3
3
 
4
+ export function petClipUrls(models, baseURI) {
5
+ try {
6
+ const base = new URL(baseURI);
7
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password) return {};
8
+ const prefix = new URL('offwork-clock/media/', base);
9
+ return Object.fromEntries(
10
+ Object.entries(models).map(([model, actions]) => [
11
+ model,
12
+ Object.fromEntries(
13
+ Object.entries(actions).flatMap(([action, path]) => {
14
+ if (typeof path !== 'string' || !path.startsWith('offwork-clock/media/')) return [];
15
+ const url = new URL(path, base);
16
+ return url.origin === base.origin &&
17
+ url.pathname.startsWith(prefix.pathname) &&
18
+ !url.search &&
19
+ !url.hash
20
+ ? [[action, url.href]]
21
+ : [];
22
+ }),
23
+ ),
24
+ ]),
25
+ );
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
4
31
  export function petVideoAlphaSupported(host, source) {
5
32
  if (PET_VIDEO_SUPPORT.has(host)) return PET_VIDEO_SUPPORT.get(host);
6
33
  const result = new Promise((resolve) => {
7
34
  const video = host.document.createElement('video');
8
- let timer;
35
+ let timer,
36
+ frame,
37
+ delayed,
38
+ done = false;
9
39
  const finish = (supported) => {
40
+ if (done) return;
41
+ done = true;
10
42
  host.clearTimeout(timer);
43
+ host.clearTimeout(delayed);
44
+ if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);
11
45
  video.onloadeddata = video.onerror = null;
12
46
  video.pause();
13
47
  video.removeAttribute('src');
@@ -18,145 +52,241 @@ export function petVideoAlphaSupported(host, source) {
18
52
  video.muted = true;
19
53
  video.playsInline = true;
20
54
  video.preload = 'auto';
21
- video.onloadeddata = () => {
55
+ const inspect = (presented = false) => {
56
+ if (done) return true;
22
57
  try {
23
58
  const canvas = host.document.createElement('canvas');
24
59
  canvas.width = canvas.height = 16;
25
60
  const context = canvas.getContext('2d', { willReadFrequently: true });
26
61
  context.drawImage(video, 0, 0, 16, 16);
27
- finish(
28
- context.getImageData(1, 1, 1, 1).data[3] > 240 &&
29
- context.getImageData(14, 14, 1, 1).data[3] < 8,
30
- );
62
+ const solid = context.getImageData(1, 1, 1, 1).data[3];
63
+ const clear = context.getImageData(14, 14, 1, 1).data[3];
64
+ if (solid > 240 && clear < 8) {
65
+ finish(true);
66
+ return true;
67
+ }
68
+ if (presented && solid > 240 && clear > 240) {
69
+ finish(false);
70
+ return true;
71
+ }
31
72
  } catch {
32
- finish(false);
73
+ /* A frame can be undecoded when loadeddata fires. */
33
74
  }
75
+ return false;
34
76
  };
35
- video.onerror = () => finish(false);
36
- timer = host.setTimeout(() => finish(false), 3000);
77
+ video.onloadeddata = () => {
78
+ if (inspect()) return;
79
+ if (video.requestVideoFrameCallback)
80
+ frame = video.requestVideoFrameCallback(() => inspect(true));
81
+ const poll = () => {
82
+ if (!inspect(true)) delayed = host.setTimeout(poll, 80);
83
+ };
84
+ delayed = host.setTimeout(poll, 80);
85
+ try {
86
+ Promise.resolve(video.play()).catch(() => {
87
+ if (!inspect()) finish(null);
88
+ });
89
+ } catch {
90
+ if (!inspect()) finish(null);
91
+ }
92
+ };
93
+ video.onerror = () => finish(null);
94
+ timer = host.setTimeout(() => {
95
+ if (!inspect()) finish(null);
96
+ }, 5000);
37
97
  video.src = source;
38
98
  video.load();
39
99
  });
40
100
  PET_VIDEO_SUPPORT.set(host, result);
101
+ result.then((supported) => {
102
+ if (supported === null && PET_VIDEO_SUPPORT.get(host) === result)
103
+ PET_VIDEO_SUPPORT.delete(host);
104
+ });
41
105
  return result;
42
106
  }
43
107
 
44
- export function createPetVideoMotion(element, host, base, clips, probeSource) {
108
+ export function createPetVideoMotion(element, host, base, clips, probeSource, options = {}) {
45
109
  const layer = element.querySelector('.owc-pet-video-layer');
110
+ const poster = element.querySelector('.owc-pet-video-poster');
46
111
  if (!layer || !clips) return base;
47
112
  const document = host.document;
48
113
  const reduced = host.matchMedia?.('(prefers-reduced-motion: reduce)');
49
- const capability = petVideoAlphaSupported(host, probeSource);
50
- const failed = new Set();
51
- const retired = new Map();
114
+ const autoIdle = options.autoIdle !== false;
115
+ const useFallback = options.fallback !== false;
116
+ const failed = new Set(),
117
+ retired = new Map();
52
118
  let enabled = false,
53
119
  resting = false,
54
120
  dragging = false,
55
- unsupported = false,
121
+ unsupported = false;
122
+ let destroyed = false,
56
123
  idleTimer = null,
57
- destroyed = false,
58
- current = null;
124
+ current = null,
125
+ displayed = null,
126
+ lastState = '';
59
127
  const allowed = () => enabled && !destroyed && !document.hidden && !reduced?.matches;
128
+ const canIdle = () =>
129
+ autoIdle &&
130
+ allowed() &&
131
+ !resting &&
132
+ !dragging &&
133
+ !unsupported &&
134
+ Object.hasOwn(clips, 'idle') &&
135
+ !failed.has('idle');
136
+ function notify(phase, action = null) {
137
+ if (destroyed || lastState === phase + ':' + action) return;
138
+ lastState = phase + ':' + action;
139
+ options.onStateChange?.({ phase, action });
140
+ }
60
141
  function clearIdleTimer() {
61
142
  host.clearTimeout(idleTimer);
62
143
  idleTimer = null;
63
144
  }
64
145
  function startIdle() {
65
- if (
66
- allowed() &&
67
- !resting &&
68
- !dragging &&
69
- !current &&
70
- !unsupported &&
71
- Object.hasOwn(clips, 'idle') &&
72
- !failed.has('idle')
73
- )
74
- play('idle');
146
+ if (canIdle() && !current) play('idle');
75
147
  }
76
148
  function scheduleIdle(delay = 0) {
77
149
  clearIdleTimer();
78
- if (!Object.hasOwn(clips, 'idle') || failed.has('idle') || unsupported || destroyed) return;
150
+ if (!canIdle()) return;
79
151
  idleTimer = host.setTimeout(() => {
80
152
  idleTimer = null;
81
153
  startIdle();
82
154
  }, delay);
83
155
  }
84
- function dispose(video) {
156
+ function dispose(run) {
157
+ if (!run || run.disposed) return;
158
+ run.disposed = true;
159
+ host.clearTimeout(run.timer);
160
+ const video = run.video;
161
+ if (!video) return;
85
162
  video.onplaying = video.onended = video.onerror = video.onwaiting = video.onstalled = null;
86
163
  video.pause();
87
164
  video.removeAttribute('src');
88
165
  video.load();
89
166
  video.remove();
90
167
  }
91
- function stop(fade = false) {
92
- clearIdleTimer();
93
- const run = current;
94
- if (!run) return;
95
- current = null;
168
+ function flushRetired() {
169
+ for (const [run, timer] of retired) {
170
+ host.clearTimeout(timer);
171
+ dispose(run);
172
+ }
173
+ retired.clear();
174
+ }
175
+ function retire(run, fade) {
176
+ if (!run?.video || !fade || !allowed()) return dispose(run);
96
177
  host.clearTimeout(run.timer);
178
+ run.video.onplaying =
179
+ run.video.onended =
180
+ run.video.onerror =
181
+ run.video.onwaiting =
182
+ run.video.onstalled =
183
+ null;
184
+ run.video.pause();
185
+ run.video.style.opacity = '0';
186
+ const timer = host.setTimeout(() => {
187
+ retired.delete(run);
188
+ dispose(run);
189
+ }, 150);
190
+ retired.set(run, timer);
191
+ }
192
+ function settleVisibleFrame() {
193
+ if (!displayed?.video) {
194
+ if (poster?.style) {
195
+ poster.style.transition = 'none';
196
+ poster.style.opacity = '1';
197
+ poster.getBoundingClientRect?.();
198
+ poster.style.opacity = '';
199
+ poster.style.transition = '';
200
+ }
201
+ return;
202
+ }
203
+ displayed.video.style.transition = 'none';
204
+ displayed.video.style.opacity = '1';
205
+ if (poster?.style) {
206
+ poster.style.transition = 'none';
207
+ poster.style.opacity = '0';
208
+ }
209
+ displayed.video.getBoundingClientRect?.();
210
+ displayed.video.style.transition = '';
211
+ if (poster?.style) {
212
+ poster.style.opacity = '';
213
+ poster.style.transition = '';
214
+ }
215
+ }
216
+ function stopMedia(fade = false) {
217
+ if (!fade && poster?.style) poster.style.transition = 'none';
218
+ clearIdleTimer();
219
+ flushRetired();
220
+ const pending = current,
221
+ visible = displayed;
222
+ current = displayed = null;
97
223
  delete element.dataset.videoPlaying;
98
- if (element.dataset.action === run.action) delete element.dataset.action;
99
- if (!run.video) return;
100
- const video = run.video;
101
- video.onplaying = video.onended = video.onerror = video.onwaiting = video.onstalled = null;
102
- video.pause();
103
- video.style.opacity = '0';
104
- if (fade && allowed()) {
105
- const timer = host.setTimeout(() => {
106
- retired.delete(video);
107
- dispose(video);
108
- }, 150);
109
- retired.set(video, timer);
110
- } else dispose(video);
224
+ delete element.dataset.videoLoading;
225
+ delete element.dataset.action;
226
+ if (pending && pending !== visible) dispose(pending);
227
+ retire(visible, fade);
228
+ if (!fade && poster?.style) {
229
+ poster.getBoundingClientRect?.();
230
+ poster.style.transition = '';
231
+ }
111
232
  }
112
233
  function fallback(run, broken = false) {
113
234
  if (current !== run) return;
114
235
  if (broken) failed.add(run.action);
115
- stop();
116
- if (allowed() && run.action !== 'idle') {
236
+ stopMedia();
237
+ notify(unsupported ? 'unsupported' : 'unavailable', run.action);
238
+ if (allowed() && run.action !== 'idle' && useFallback) {
117
239
  base.play(run.action);
118
240
  scheduleIdle(1400);
119
241
  }
120
242
  }
121
243
  function play(action) {
122
244
  if (!allowed() || dragging || element.dataset.action === 'drag') return false;
123
- if (action === 'idle' && (resting || current || unsupported)) return false;
245
+ if (action === 'idle' && (resting || (current && !options.interruptible) || unsupported))
246
+ return false;
124
247
  if (
125
248
  current &&
126
249
  (action === 'look' ||
127
250
  current.action === action ||
128
- (current.action === 'celebrate' && action === 'greet'))
251
+ (!options.interruptible && current.action === 'celebrate' && action === 'greet'))
129
252
  )
130
253
  return false;
131
254
  if (!Object.hasOwn(clips, action) || failed.has(action) || unsupported) {
132
255
  if (action === 'idle') return false;
133
- if (current?.action === 'idle') stop();
134
- const played = base.play(action);
256
+ if (current?.action === 'idle') stopMedia();
257
+ const played = useFallback && base.play(action);
135
258
  if (played) scheduleIdle(1400);
136
- return played;
259
+ if (!played) notify(unsupported ? 'unsupported' : 'unavailable', action);
260
+ return !!played;
137
261
  }
138
- stop();
262
+ clearIdleTimer();
263
+ settleVisibleFrame();
264
+ flushRetired();
265
+ if (current && current !== displayed) dispose(current);
139
266
  base.release(true);
140
- const run = { action, video: null, timer: null };
267
+ const run = { action, video: null, timer: null, disposed: false };
141
268
  current = run;
142
- element.dataset.action = action;
269
+ element.dataset.action = displayed?.action || action;
270
+ element.dataset.videoLoading = action;
271
+ notify('loading', action);
143
272
  run.timer = host.setTimeout(() => fallback(run, true), 9000);
144
- capability.then((supported) => {
273
+ petVideoAlphaSupported(host, probeSource).then((supported) => {
145
274
  if (current !== run || !allowed()) return;
146
- if (!supported) {
147
- unsupported = true;
275
+ if (supported !== true) {
276
+ unsupported = supported === false;
148
277
  return fallback(run);
149
278
  }
150
279
  const video = document.createElement('video');
151
280
  run.video = video;
152
- video.muted = true;
153
- video.defaultMuted = true;
281
+ video.muted = video.defaultMuted = true;
154
282
  video.playsInline = true;
155
283
  video.loop = action === 'idle';
156
284
  video.disablePictureInPicture = true;
157
285
  video.preload = 'auto';
286
+ video.style.opacity = '0';
158
287
  video.setAttribute('aria-hidden', 'true');
159
288
  video.setAttribute('playsinline', '');
289
+ video.setAttribute('tabindex', '-1');
160
290
  video.onplaying = () => {
161
291
  if (current !== run || !allowed()) return;
162
292
  if (!Number.isFinite(video.duration) || video.duration <= 0 || video.duration > 15)
@@ -166,7 +296,14 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
166
296
  action === 'idle'
167
297
  ? null
168
298
  : host.setTimeout(() => fallback(run, true), video.duration * 1000 + 2000);
299
+ const previous = displayed;
300
+ displayed = run;
301
+ video.style.opacity = '1';
302
+ element.dataset.action = action;
169
303
  element.dataset.videoPlaying = 'true';
304
+ delete element.dataset.videoLoading;
305
+ if (previous !== run) retire(previous, true);
306
+ notify('playing', action);
170
307
  };
171
308
  video.onwaiting = video.onstalled = () => {
172
309
  if (current !== run) return;
@@ -174,9 +311,15 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
174
311
  run.timer = host.setTimeout(() => fallback(run, true), 4000);
175
312
  };
176
313
  video.onended = () => {
177
- if (current === run) {
178
- stop(true);
314
+ if (current !== run) return;
315
+ host.clearTimeout(run.timer);
316
+ if (canIdle()) {
317
+ current = null;
318
+ if (displayed !== run) dispose(run);
179
319
  scheduleIdle();
320
+ } else {
321
+ stopMedia(true);
322
+ notify('idle');
180
323
  }
181
324
  };
182
325
  video.onerror = () => fallback(run, true);
@@ -190,6 +333,11 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
190
333
  });
191
334
  return true;
192
335
  }
336
+ function stop() {
337
+ stopMedia();
338
+ base.release(true);
339
+ notify('idle');
340
+ }
193
341
  const visibility = () => {
194
342
  if (!allowed()) stop();
195
343
  else scheduleIdle();
@@ -198,16 +346,22 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
198
346
  reduced?.addEventListener?.('change', visibility);
199
347
  return {
200
348
  play,
349
+ stop,
350
+ retry(action) {
351
+ failed.delete(action);
352
+ return play(action);
353
+ },
201
354
  configure(next) {
202
- if (!next.enabled || (next.resting && !resting)) stop();
355
+ if (!next.enabled || (next.resting && !resting)) stopMedia();
203
356
  enabled = next.enabled;
204
357
  resting = next.resting;
205
358
  base.configure(next);
359
+ if (!enabled) notify('idle');
206
360
  startIdle();
207
361
  },
208
362
  drag(direction) {
209
363
  dragging = true;
210
- stop();
364
+ stopMedia();
211
365
  base.drag(direction);
212
366
  },
213
367
  release(cancelled) {
@@ -217,12 +371,7 @@ export function createPetVideoMotion(element, host, base, clips, probeSource) {
217
371
  },
218
372
  destroy() {
219
373
  destroyed = true;
220
- stop();
221
- for (const [video, timer] of retired) {
222
- host.clearTimeout(timer);
223
- dispose(video);
224
- }
225
- retired.clear();
374
+ stopMedia();
226
375
  document.removeEventListener('visibilitychange', visibility);
227
376
  reduced?.removeEventListener?.('change', visibility);
228
377
  base.destroy();
package/pet-view.js CHANGED
@@ -33,6 +33,8 @@ export const PET_MODELS = Object.freeze({
33
33
  'xiaoxie-original': { ...PET_MODEL, widthScale: 1, heightScale: 1.1, label: 'petDroplet' },
34
34
  });
35
35
  export function petModel(id) {
36
+ if (id === 'custom-image')
37
+ return { id, renderer: 'image', widthScale: 1.3125, heightScale: 1.75, label: 'petCharacter' };
36
38
  return Object.hasOwn(PET_MODELS, id) ? PET_MODELS[id] : PET_MODELS['whale-dress'];
37
39
  }
38
40
 
@@ -42,6 +44,8 @@ export function petLayout(
42
44
  size = 132,
43
45
  contentHeight = 420,
44
46
  modelId = 'xiaoxie-original',
47
+ footerHeight = 30,
48
+ footerWidth = 148,
45
49
  ) {
46
50
  const model = petModel(modelId);
47
51
  const originX = viewport.left ?? 0,
@@ -55,14 +59,14 @@ export function petLayout(
55
59
  Math.min(
56
60
  size,
57
61
  Math.max(1, (width - edge * 2) / model.widthScale),
58
- Math.max(1, Math.floor((height - edge * 2 - 30) / model.heightScale)),
62
+ Math.max(1, Math.floor((height - edge * 2 - footerHeight) / model.heightScale)),
59
63
  ),
60
64
  );
61
65
  const petWidth = Math.min(
62
66
  Math.max(1, width - edge * 2),
63
- Math.max(148, artworkSize * model.widthScale),
67
+ Math.max(footerWidth, artworkSize * model.widthScale),
64
68
  );
65
- const petHeight = Math.ceil(artworkSize * model.heightScale) + 30;
69
+ const petHeight = Math.ceil(artworkSize * model.heightScale) + footerHeight;
66
70
  const bound = (n, max) => Math.max(Math.min(edge, max), Math.min(n, max));
67
71
  const x = bound(
68
72
  Number.isFinite(position?.x) ? position.x - originX : width - petWidth - 32,
@@ -253,6 +257,10 @@ export function petArtwork(
253
257
  export const PET_MESSAGES = {
254
258
  zh: {
255
259
  petGreet: '与角色互动',
260
+ petNeedsSession: '等待会话',
261
+ petNeedsSetup: '请配置作息',
262
+ petLoadFailed: '读取失败',
263
+ petRetryHint: '右键菜单可重新读取设置',
256
264
  petCharacter: '角色形象',
257
265
  petWhaleActions: '鲸蓝少女 · 长裙动态',
258
266
  petWhaleOrnateActions: '鲸蓝少女 · 短裙动态',
@@ -266,9 +274,29 @@ export const PET_MESSAGES = {
266
274
  petHint: '左键互动 · 右键菜单 · 拖动移动',
267
275
  petMode: '宠物模式',
268
276
  petClock: '下班倒计时',
277
+ petPreviewActions: '动作试播',
278
+ petPreviewIdle: '待机',
279
+ petPreviewGreet: '挥手',
280
+ petPreviewDrink: '喝茶',
281
+ petPreviewStop: '停止',
282
+ petTryIdle: '试播待机',
283
+ petTryGreet: '试播挥手',
284
+ petTryDrink: '试播喝茶',
285
+ petStopPreview: '停止试播',
286
+ petPreviewHint: '仅预览,不记喝水次数,也不更换页面角色。',
287
+ petPreviewLoading: '正在准备动作…',
288
+ petPreviewPlaying: '正在试播',
289
+ petPreviewUnsupported: '当前浏览器不支持透明动作视频,已显示静态预览。',
290
+ petPreviewUnavailable: '动作暂时无法播放,已恢复静态预览。可以重试。',
291
+ petPreviewEnable: '勾选“角色动作反馈”后可试播;应用后才保存。',
292
+ petPreviewReduced: '系统已开启减少动态效果,当前显示静态预览。',
269
293
  },
270
294
  en: {
271
295
  petGreet: 'Interact with character',
296
+ petNeedsSession: 'Open a session',
297
+ petNeedsSetup: 'Set your schedule',
298
+ petLoadFailed: 'Settings unavailable',
299
+ petRetryHint: 'Retry loading settings from the context menu',
272
300
  petCharacter: 'Character',
273
301
  petWhaleActions: 'Blue whale · Animated long dress',
274
302
  petWhaleOrnateActions: 'Blue whale · Animated short dress',
@@ -282,6 +310,23 @@ export const PET_MESSAGES = {
282
310
  petHint: 'Click to interact · right-click for menu · drag to move',
283
311
  petMode: 'Pet mode',
284
312
  petClock: 'Shift countdown',
313
+ petPreviewActions: 'Preview actions',
314
+ petPreviewIdle: 'Idle',
315
+ petPreviewGreet: 'Wave',
316
+ petPreviewDrink: 'Drink',
317
+ petPreviewStop: 'Stop',
318
+ petTryIdle: 'Preview idle',
319
+ petTryGreet: 'Preview wave',
320
+ petTryDrink: 'Preview drinking',
321
+ petStopPreview: 'Stop preview',
322
+ petPreviewHint: 'Preview only: no drinking record or change to the page character.',
323
+ petPreviewLoading: 'Preparing action…',
324
+ petPreviewPlaying: 'Previewing',
325
+ petPreviewUnsupported:
326
+ 'This browser cannot show transparent action videos. Showing a static preview.',
327
+ petPreviewUnavailable: 'Action unavailable. Static preview restored; you can retry.',
328
+ petPreviewEnable: 'Enable character reactions to preview. Apply to save the choice.',
329
+ petPreviewReduced: 'Reduced motion is enabled. Showing a static preview.',
285
330
  },
286
331
  };
287
332
 
@@ -291,8 +336,9 @@ export const PET_STYLE = `
291
336
  .owc-v2 button.owc-pet-handle{padding:0;background:none!important;border-radius:50%;min-height:0;display:block;cursor:grab;touch-action:none;line-height:0}
292
337
  .owc-v2 button.owc-pet-handle:active{cursor:grabbing}
293
338
  .owc-pet-art{overflow:visible;pointer-events:none;filter:drop-shadow(0 3px 5px #32557418)}
294
- .owc-pet-video-art{display:block;position:relative}
339
+ .owc-pet-video-art{display:block;position:relative;isolation:isolate}
295
340
  .owc-pet-video-poster,.owc-pet-video-layer,.owc-pet-video-layer video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}
341
+ .owc-pet-video-poster,.owc-pet-video-layer video{mix-blend-mode:plus-lighter}
296
342
  .owc-pet-video-poster{opacity:1;transition:opacity .12s linear}
297
343
  .owc-pet-video-layer video{opacity:0;transition:opacity .12s linear}
298
344
  .owc-pet-video-art[data-video-playing=true] .owc-pet-video-poster{opacity:0}
@@ -309,6 +355,27 @@ export const PET_STYLE = `
309
355
  .owc-v2 .owc-pet-clock{display:flex;align-items:center;color:var(--owc-text);line-height:1.5;cursor:default;padding:4px 10px;min-height:26px;gap:5px;white-space:nowrap;border-radius:999px;font-size:11px;background:var(--dsw-alias-bg-overlay);border:1px solid color-mix(in srgb,var(--owc-accent) 25%,transparent);box-shadow:0 3px 12px #142e4812;font-variant-numeric:tabular-nums;max-width:100%;box-sizing:border-box;overflow:hidden}
310
356
  .owc-pet-clock-label{min-width:0;overflow:hidden;text-overflow:ellipsis}
311
357
  .owc-pet-clock strong{flex:none}
358
+ .owc-v2 .owc-pet-timers{width:196px;max-width:100%;margin-top:4px;box-sizing:border-box;border:1px solid var(--owc-border);border-radius:13px;background:var(--owc-surface);box-shadow:var(--owc-shadow);overflow:hidden;font-variant-numeric:tabular-nums}
359
+ .owc-v2 .owc-pet-timers .owc-pet-clock{width:100%;height:34px;min-height:0;padding:5px 10px;gap:6px;border:0;border-radius:0;background:transparent;box-shadow:none;font-size:10px;color:var(--owc-muted)}
360
+ .owc-pet-timers .owc-pet-clock svg{flex-shrink:0}
361
+ .owc-pet-timers .owc-pet-clock strong{margin-left:auto;font-size:16px;line-height:1;color:var(--owc-text);letter-spacing:.2px}
362
+ .owc-pet-timer-pair{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);border-top:1px solid var(--owc-border)}
363
+ .owc-v2 .owc-pet-timer-row{display:flex;align-items:center;justify-content:center;gap:5px;box-sizing:border-box;min-width:0;height:28px;padding:3px 6px;font-size:11px;white-space:nowrap;color:var(--owc-muted)}
364
+ .owc-pet-timer-row+.owc-pet-timer-row{border-left:1px solid var(--owc-border)}
365
+ .owc-pet-timer-label{display:flex;align-items:center;flex-shrink:0}
366
+ .owc-pet-timer-label svg{flex-shrink:0}
367
+ .owc-pet-timer-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:550;color:var(--owc-text)}
368
+ .owc-timer-pause{font-size:9px;margin-right:3px;color:var(--owc-muted)}
369
+ .owc-v2 .owc-pet-timer-row[data-countdown-due=true]{background:color-mix(in srgb,var(--owc-accent) 8%,transparent);color:color-mix(in srgb,var(--owc-accent) 55%,var(--owc-text))}
370
+ .owc-pet-timer-row[data-countdown-due=true] strong{color:inherit}
371
+ .owc-v2 [data-countdown-due=true]{color:var(--owc-accent);border-color:var(--owc-accent)}
372
+ .owc-countdown-card{padding:16px;display:flex;flex-direction:column;gap:14px}
373
+ .owc-countdown-value{font-size:32px;line-height:1.3;font-weight:650;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
374
+ .owc-countdown-actions{display:flex;flex-wrap:wrap;gap:8px}
375
+ .owc-countdown-actions button{flex:1;min-width:110px;min-height:40px}
376
+ .owc-countdown-card label{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}
377
+ .owc-v2 .owc-countdown-card select{flex:0 0 120px;width:120px;max-width:120px}
378
+ .owc-countdown-card p{margin:0;font-size:12px;line-height:1.6;overflow-wrap:anywhere}
312
379
  .owc-pet-panel{position:fixed;border-radius:20px;cursor:default;touch-action:manipulation;max-width:calc(100vw - 24px)}
313
380
  .owc-pet-panel>.owc-content{max-height:inherit}
314
381
  .owc-pet-panel .owc-glass{opacity:max(var(--owc-alpha),.94)}