dsh-offwork 1.1.1 → 1.1.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.
@@ -5,12 +5,13 @@ import { createPetVideoMotion, petVideoAlphaSupported } from '../pet-video.js';
5
5
  const flush = async () => {
6
6
  for (let i = 0; i < 5; i++) await Promise.resolve();
7
7
  };
8
- function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
8
+ function fixture({ alpha = true, rejectPlay = false, idle = false, options = {} } = {}) {
9
9
  const media = Object.assign(new EventTarget(), { matches: false });
10
10
  const document = Object.assign(new EventTarget(), { hidden: false });
11
11
  const videos = [],
12
12
  timers = new Map(),
13
- calls = [];
13
+ calls = [],
14
+ states = [];
14
15
  let nextTimer = 0;
15
16
  document.createElement = (tag) => {
16
17
  if (tag === 'canvas')
@@ -26,6 +27,11 @@ function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
26
27
  duration: 5,
27
28
  removed: false,
28
29
  canPlayType: () => 'probably',
30
+ requestVideoFrameCallback(callback) {
31
+ queueMicrotask(callback);
32
+ return 1;
33
+ },
34
+ cancelVideoFrameCallback() {},
29
35
  setAttribute() {},
30
36
  removeAttribute(name) {
31
37
  delete this[name];
@@ -81,9 +87,10 @@ function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
81
87
  base,
82
88
  { greet: 'greet-data', celebrate: 'drink-data', ...(idle ? { idle: 'idle-data' } : {}) },
83
89
  'probe',
90
+ { ...options, onStateChange: (state) => states.push(state) },
84
91
  );
85
92
  motion.configure({ enabled: true, resting: false });
86
- return { host, document, media, element, base, motion, videos, timers, calls };
93
+ return { host, document, media, element, base, motion, videos, timers, calls, states };
87
94
  }
88
95
 
89
96
  test('alpha support checks decoded pixels rather than trusting WebM codec support', async () => {
@@ -112,7 +119,15 @@ test('clips reveal only after playing; accepted drinking interrupts greeting and
112
119
  f.motion.play('celebrate');
113
120
  await flush();
114
121
  const drink = f.videos.at(-1);
122
+ assert.equal(greet.removed, false);
123
+ assert.equal(greet.paused, false);
124
+ assert.equal(f.element.dataset.action, 'greet');
125
+ assert.equal(f.element.dataset.videoLoading, 'celebrate');
126
+ assert.equal(drink.style.opacity, '0');
115
127
  drink.onplaying();
128
+ assert.equal(greet.paused, true);
129
+ assert.equal(greet.style.opacity, '0');
130
+ await fireTimer(f, 150);
116
131
  assert.equal(greet.removed, true);
117
132
  staleEnd();
118
133
  assert.equal(f.element.dataset.action, 'celebrate');
@@ -121,7 +136,7 @@ test('clips reveal only after playing; accepted drinking interrupts greeting and
121
136
  drink.onended();
122
137
  assert.equal(f.element.dataset.videoPlaying, undefined);
123
138
  assert.equal(f.element.dataset.action, undefined);
124
- for (const timer of [...f.timers.values()]) if (timer.ms === 150) timer.fn();
139
+ await fireTimer(f, 150);
125
140
  assert.equal(drink.removed, true);
126
141
  f.motion.destroy();
127
142
  });
@@ -219,6 +234,8 @@ test('idle loops are interrupted by actions and resume once after action complet
219
234
  await flush();
220
235
  const greet = f.videos.at(-1);
221
236
  greet.onplaying();
237
+ assert.equal(idle.paused, true);
238
+ await fireTimer(f, 150);
222
239
  assert.equal(idle.removed, true);
223
240
  assert.equal(greet.loop, false);
224
241
  const oldEnd = greet.onended;
@@ -274,7 +291,7 @@ test('quiet state and a held drag suppress idle until they finish', async () =>
274
291
  f.motion.play('greet');
275
292
  await flush();
276
293
  f.videos.at(-1).onended();
277
- await fireTimer(f, 0);
294
+ await flush();
278
295
  assert.equal(f.element.dataset.action, undefined);
279
296
  f.motion.configure({ enabled: true, resting: false });
280
297
  await flush();
@@ -335,3 +352,152 @@ test('failed action fallback suspends idle during CSS feedback and then returns
335
352
  f.motion.destroy();
336
353
  assert.equal(f.timers.size, 0);
337
354
  });
355
+
356
+ test('preview waits for explicit play, switches actions freely and never resumes idle automatically', async () => {
357
+ const f = fixture({
358
+ idle: true,
359
+ options: { autoIdle: false, fallback: false, interruptible: true },
360
+ });
361
+ await flush();
362
+ assert.equal(f.videos.length, 0);
363
+ f.motion.play('greet');
364
+ await flush();
365
+ const greet = f.videos.at(-1);
366
+ greet.onplaying();
367
+ const announcements = f.states.length;
368
+ greet.onplaying();
369
+ assert.equal(f.states.length, announcements);
370
+ greet.onended();
371
+ await flush();
372
+ assert.equal(f.states.at(-1).phase, 'idle');
373
+ assert.equal(f.videos.length, 2);
374
+ await fireTimer(f, 150);
375
+ f.motion.play('idle');
376
+ await flush();
377
+ f.videos.at(-1).onplaying();
378
+ f.motion.play('celebrate');
379
+ await flush();
380
+ f.videos.at(-1).onplaying();
381
+ assert.equal(f.motion.play('greet'), true);
382
+ await flush();
383
+ f.videos.at(-1).onplaying();
384
+ assert.equal(f.motion.play('idle'), true);
385
+ await flush();
386
+ f.videos.at(-1).onplaying();
387
+ assert.ok(f.videos.filter((v) => v.src && !v.removed).length <= 2);
388
+ f.motion.stop();
389
+ assert.equal(f.element.dataset.videoPlaying, undefined);
390
+ assert.equal(f.states.at(-1).phase, 'idle');
391
+ assert.deepEqual(f.calls, []);
392
+ f.motion.destroy();
393
+ assert.equal(f.timers.size, 0);
394
+ });
395
+
396
+ test('preview cancellation ignores late probe and video callbacks', async () => {
397
+ const f = fixture({ options: { autoIdle: false, fallback: false, interruptible: true } });
398
+ f.motion.play('greet');
399
+ f.motion.stop();
400
+ await flush();
401
+ assert.equal(f.videos.length, 1);
402
+ assert.deepEqual(
403
+ f.states.map((s) => s.phase),
404
+ ['loading', 'idle'],
405
+ );
406
+ f.motion.play('greet');
407
+ await flush();
408
+ const late = f.videos.at(-1).onplaying;
409
+ f.motion.stop();
410
+ late();
411
+ assert.equal(f.states.at(-1).phase, 'idle');
412
+ const count = f.states.length;
413
+ f.motion.destroy();
414
+ late();
415
+ assert.equal(f.states.length, count);
416
+ assert.equal(f.timers.size, 0);
417
+ });
418
+
419
+ test('preview reports media failure without pretending to play and supports explicit retry', async () => {
420
+ const f = fixture({
421
+ rejectPlay: true,
422
+ options: { autoIdle: false, fallback: false, interruptible: true },
423
+ });
424
+ f.motion.play('greet');
425
+ await flush();
426
+ assert.equal(f.states.at(-1).phase, 'unavailable');
427
+ assert.deepEqual(f.calls, []);
428
+ const create = f.document.createElement;
429
+ f.document.createElement = (tag) => {
430
+ const node = create(tag);
431
+ if (tag === 'video') node.play = () => Promise.resolve();
432
+ return node;
433
+ };
434
+ assert.equal(f.motion.retry('greet'), true);
435
+ await flush();
436
+ f.videos.at(-1).onplaying();
437
+ assert.equal(f.states.at(-1).phase, 'playing');
438
+ f.motion.destroy();
439
+ assert.equal(f.timers.size, 0);
440
+ });
441
+
442
+ test('preview distinguishes unsupported transparency from retryable resource failures', async () => {
443
+ const f = fixture({
444
+ alpha: false,
445
+ options: { autoIdle: false, fallback: false, interruptible: true },
446
+ });
447
+ f.motion.play('greet');
448
+ await flush();
449
+ assert.equal(f.states.at(-1).phase, 'unsupported');
450
+ assert.deepEqual(f.calls, []);
451
+ const count = f.videos.length;
452
+ f.motion.retry('greet');
453
+ await flush();
454
+ assert.equal(f.videos.length, count);
455
+ assert.equal(f.states.at(-1).phase, 'unsupported');
456
+ f.motion.destroy();
457
+ assert.equal(f.timers.size, 0);
458
+ });
459
+
460
+ test('alpha detection waits for a readable frame rather than treating an empty canvas as unsupported', async () => {
461
+ const f = fixture();
462
+ const create = f.document.createElement;
463
+ let reads = 0;
464
+ f.document.createElement = (tag) => {
465
+ const node = create(tag);
466
+ if (tag === 'canvas')
467
+ node.getContext = () => ({
468
+ drawImage() {},
469
+ getImageData(x) {
470
+ reads++;
471
+ return { data: [0, 0, 0, reads <= 2 ? 0 : x === 1 ? 255 : 0] };
472
+ },
473
+ });
474
+ return node;
475
+ };
476
+ assert.equal(await petVideoAlphaSupported(f.host, 'probe'), true);
477
+ assert.ok(reads >= 4);
478
+ f.motion.destroy();
479
+ assert.equal(f.timers.size, 0);
480
+ });
481
+
482
+ test('a probe timeout remains retryable instead of poisoning the browser capability cache', async () => {
483
+ const f = fixture();
484
+ const create = f.document.createElement;
485
+ f.document.createElement = (tag) => {
486
+ const node = create(tag);
487
+ if (tag === 'video') node.load = () => {};
488
+ if (tag === 'canvas')
489
+ node.getContext = () => ({
490
+ drawImage() {
491
+ throw new Error('no decoded frame');
492
+ },
493
+ });
494
+ return node;
495
+ };
496
+ const first = petVideoAlphaSupported(f.host, 'probe');
497
+ await fireTimer(f, 5000);
498
+ assert.equal(await first, null);
499
+ f.document.createElement = create;
500
+ assert.equal(await petVideoAlphaSupported(f.host, 'probe'), true);
501
+ f.motion.destroy();
502
+ assert.equal(f.timers.size, 0);
503
+ });
package/tools/build.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
2
3
 
3
4
  const root = new URL('../', import.meta.url);
4
5
  const moduleNames = [
@@ -9,10 +10,12 @@ const moduleNames = [
9
10
  'pet-view.js',
10
11
  'pet-motion.js',
11
12
  'pet-video.js',
13
+ 'pet-reminder.js',
12
14
  'pet-interactions.js',
13
15
  'pet-surfaces.js',
14
16
  'hydration.js',
15
17
  'hydration-store.js',
18
+ 'countdown.js',
16
19
  ];
17
20
  const [entry, ...modules] = await Promise.all(
18
21
  moduleNames.map((name) => readFile(new URL(name, root), 'utf8')),
@@ -26,7 +29,9 @@ if (entry.split(marker).length !== 2) {
26
29
  // The browser and Node.js tests execute the same calculation and store code.
27
30
  const pure = modules
28
31
  .map((source) =>
29
- source.replace(/^import .* from '\.\/hydration\.js';\n/gm, '').replace(/^export /gm, ''),
32
+ source
33
+ .replace(/^import\s+\{[^}]+\}\s+from '\.\/(?:hydration|clock)\.js';\n/gm, '')
34
+ .replace(/^export /gm, ''),
30
35
  )
31
36
  .join('\n');
32
37
  const banner = '// Generated by tools/build.mjs; edit the source modules, not this file.\n';
@@ -44,14 +49,20 @@ const sprites = Object.fromEntries(
44
49
  );
45
50
  const clipMarker = '/* PET_CLIPS */';
46
51
  if (entry.split(clipMarker).length !== 2) throw new Error('Expected one clip marker');
47
- async function clip(name, maximum) {
52
+ const mediaRoute = '/offwork-clock/media';
53
+ const mediaAssets = {};
54
+ async function clip(name, maximum, inline = false) {
48
55
  const bytes = await readFile(new URL(`assets/animations/${name}.webm`, root));
49
- if (bytes.readUInt32BE(0) !== 0x1a45dfa3 || bytes.length > maximum)
56
+ if (bytes.length < 4 || bytes.readUInt32BE(0) !== 0x1a45dfa3 || bytes.length > maximum)
50
57
  throw new Error(`Invalid or oversized WebM: ${name}`);
51
- return 'data:video/webm;base64,' + bytes.toString('base64');
58
+ if (inline) return 'data:video/webm;base64,' + bytes.toString('base64');
59
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
60
+ const key = `${sha256}/${name}.webm`;
61
+ mediaAssets[key] = { file: `assets/animations/${name}.webm`, size: bytes.length, sha256 };
62
+ return `${mediaRoute.slice(1)}/${key}`;
52
63
  }
53
64
  const [probe, greet, celebrate, ornateIdle, ornateGreet, ornateDrink] = await Promise.all([
54
- clip('alpha-probe', 10000),
65
+ clip('alpha-probe', 10000, true),
55
66
  clip('whale-greet', 2000000),
56
67
  clip('whale-drink', 2000000),
57
68
  clip('ornate-idle', 2000000),
@@ -70,15 +81,27 @@ const result =
70
81
  entry
71
82
  .replace(marker, pure)
72
83
  .replace(spriteMarker, () => `const PET_SPRITES = ${JSON.stringify(sprites)};`)
73
- .replace(clipMarker, () => `const PET_CLIPS = ${JSON.stringify(clips)};`);
74
- const output = new URL('client.js', root);
75
-
76
- if (process.argv.includes('--check')) {
77
- if ((await readFile(output, 'utf8')) !== result) {
78
- throw new Error('Client artifact is stale. Run npm run build and commit client.js.');
79
- }
80
- console.log('Client artifact matches tested source');
81
- } else {
82
- await writeFile(output, result);
83
- console.log('Built client.js from one calculation source');
84
+ .replace(
85
+ clipMarker,
86
+ () =>
87
+ `const PET_CLIPS = ${JSON.stringify(clips)};\nPET_CLIPS.models = petClipUrls(PET_CLIPS.models, document.baseURI);`,
88
+ );
89
+ const manifest =
90
+ banner +
91
+ `export const MEDIA_ROUTE = ${JSON.stringify(mediaRoute)};\nexport const MEDIA_ASSETS = Object.freeze(${JSON.stringify(Object.fromEntries(Object.entries(mediaAssets).sort()), null, 2)});\n`;
92
+ const outputs = [
93
+ ['client.js', result],
94
+ ['media-manifest.js', manifest],
95
+ ];
96
+ for (const [name, contents] of outputs) {
97
+ const output = new URL(name, root);
98
+ if (process.argv.includes('--check')) {
99
+ if ((await readFile(output, 'utf8')) !== contents)
100
+ throw new Error(`Generated artifact is stale: ${name}. Run npm run build.`);
101
+ } else await writeFile(output, contents);
84
102
  }
103
+ console.log(
104
+ process.argv.includes('--check')
105
+ ? 'Client and media manifest match tested source'
106
+ : 'Built client and content-addressed media manifest',
107
+ );
@@ -16,6 +16,10 @@ assert.ok(pkg.engines?.dsh, 'Declare the supported Harness versions');
16
16
  assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml');
17
17
  assert.equal(pkg.dsh?.client?.platform, 'web');
18
18
  assert.match(await read('LICENSE'), /^MIT License/);
19
+ assert.ok(
20
+ Buffer.byteLength(await read('client.js')) < 2000000,
21
+ 'Keep the initial client below 2MB; character movies must remain on-demand resources',
22
+ );
19
23
 
20
24
  // This check covers this project's patch; Harness composition remains the
21
25
  // authoritative check that the bundle can be installed in a real profile.
@@ -67,15 +71,19 @@ assert.equal(packed.version, pkg.version);
67
71
  for (const path of [
68
72
  'package.json',
69
73
  'index.js',
74
+ 'media-server.js',
75
+ 'media-manifest.js',
70
76
  'client.js',
71
77
  'companion.js',
72
78
  'pet-view.js',
73
79
  'pet-motion.js',
74
80
  'pet-video.js',
81
+ 'pet-reminder.js',
75
82
  'pet-interactions.js',
76
83
  'pet-surfaces.js',
77
84
  'hydration.js',
78
85
  'hydration-store.js',
86
+ 'countdown.js',
79
87
  'cordis.patch.yml',
80
88
  'icon.svg',
81
89
  'assets/characters/whale-dress.webp',
package/ui-utils.js CHANGED
@@ -7,6 +7,50 @@ export function formatDuration(seconds) {
7
7
  .join(':');
8
8
  }
9
9
 
10
+ export function classicClockPresentation({ hasSession, loaded, configured, failed, state, now }) {
11
+ if (!hasSession)
12
+ return { phase: 'waiting', title: 'classicNoSession', hint: 'noSession', seconds: null };
13
+ if (failed)
14
+ return { phase: 'error', title: 'classicUnavailable', hint: 'classicRetryHint', seconds: null };
15
+ if (!loaded)
16
+ return { phase: 'loading', title: 'classicLoading', hint: 'classicLoadingHint', seconds: null };
17
+ if (!configured) return { phase: 'setup', title: 'setup', hint: 'setupHint', seconds: null };
18
+ switch (state?.status) {
19
+ case 'before':
20
+ return {
21
+ phase: 'before',
22
+ title: 'before',
23
+ hint: 'classicBeforeHint',
24
+ seconds: state.startRemaining,
25
+ };
26
+ case 'working':
27
+ return {
28
+ phase: 'working',
29
+ title: 'working',
30
+ hint: 'classicWorkingHint',
31
+ seconds: state.remaining,
32
+ };
33
+ case 'lunch':
34
+ return {
35
+ phase: 'lunch',
36
+ title: 'classicLunchRemaining',
37
+ hint: 'classicLunchHint',
38
+ seconds: Math.max(0, Math.ceil((state.breakEnd - now) / 1000)),
39
+ };
40
+ case 'done':
41
+ return { phase: 'done', title: 'done', hint: 'classicDoneHint', seconds: null };
42
+ case 'rest':
43
+ return { phase: 'rest', title: 'rest', hint: 'classicRestHint', seconds: null };
44
+ default:
45
+ return {
46
+ phase: 'error',
47
+ title: 'classicUnavailable',
48
+ hint: 'classicRetryHint',
49
+ seconds: null,
50
+ };
51
+ }
52
+ }
53
+
10
54
  export function clampPosition(position, viewport, size) {
11
55
  const width = Math.max(0, viewport.width),
12
56
  height = Math.max(0, viewport.height);