@kaydash9999/banker-plugins 0.22.0 → 0.23.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +68 -0
  4. package/PRIVACY.md +1 -0
  5. package/README.md +39 -13
  6. package/bin/banker.js +118 -22
  7. package/bin/lib/codex-hooks.mjs +116 -18
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +93 -1
  10. package/bin/lib/command-skill.cjs +37 -6
  11. package/codex/command-skills/front-qa/SKILL.md +1 -1
  12. package/codex/command-skills/setup/SKILL.md +4 -4
  13. package/codex/hooks.json +5 -5
  14. package/codex/manifest.json +18 -15
  15. package/codex/skills/progress/scripts/progress.mjs +44 -14
  16. package/codex/skills/teamwork/SKILL.md +143 -0
  17. package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
  18. package/codex/transform-matrix.md +5 -3
  19. package/hooks/obsidize-hook.mjs +75 -32
  20. package/hooks/telemetry-count-codex.mjs +238 -57
  21. package/hooks/update-fetch.mjs +2 -1
  22. package/hooks/update-notify.mjs +82 -20
  23. package/package.json +3 -1
  24. package/skills/3d-intro-build/SKILL.md +101 -20
  25. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  26. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  27. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  28. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  29. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  30. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  31. package/skills/3d-intro-build/references/prompts.md +51 -4
  32. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  33. package/skills/3d-intro-build/references/step-nav.css +29 -0
  34. package/skills/3d-intro-build/references/step-nav.js +284 -106
  35. package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
  36. package/skills/3d-intro-build/references/verify.md +6 -0
  37. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  38. package/skills/3d-intro-setup/SKILL.md +20 -7
  39. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  40. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  41. package/skills/all-in-one/SKILL.md +125 -86
  42. package/skills/omc-patch/SKILL.md +1 -1
  43. package/skills/payload-mon/SKILL.md +1 -1
  44. package/skills/play-qa/SKILL.md +6 -4
  45. package/skills/progress-mon/README.md +164 -0
  46. package/skills/progress-mon/SKILL.md +165 -0
  47. package/skills/progress-mon/assets/fixture/README.md +1 -0
  48. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  49. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  50. package/skills/progress-mon/assets/fixture/config.json +30 -0
  51. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  52. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  53. package/skills/progress-mon/assets/fixture/items.json +14 -0
  54. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  55. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  56. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  57. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  58. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  59. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  60. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  61. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  62. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  63. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  64. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  65. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  66. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  67. package/skills/progress-mon/references/arch.md +280 -0
  68. package/skills/progress-mon/references/authoring.md +199 -0
  69. package/skills/progress-mon/references/config.md +115 -0
  70. package/skills/progress-mon/references/flow.md +75 -0
  71. package/skills/progress-mon/references/inventory.md +58 -0
  72. package/skills/progress-mon/references/items.md +74 -0
  73. package/skills/progress-mon/references/req-spec.md +74 -0
  74. package/skills/progress-mon/references/reqs.md +30 -0
  75. package/skills/progress-mon/scripts/index.js +31 -0
  76. package/skills/progress-mon/scripts/pm-check.mjs +549 -0
  77. package/skills/progress-mon/scripts/pm-config.mjs +343 -0
  78. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  79. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  80. package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
  81. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  82. package/skills/progress-mon/scripts/pm-server.mjs +781 -0
  83. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  84. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  85. package/skills/ralph-qa/SKILL.md +107 -17
  86. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  87. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  88. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  89. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  90. package/skills/ralph-qa/references/seat-run.mjs +200 -159
  91. package/skills/remains/SKILL.md +2 -0
  92. package/skills/remains/scripts/boxes.mjs +6 -2
  93. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  94. package/skills/setup-mcp/SKILL.md +1 -1
  95. package/skills/setup-omc-hud/SKILL.md +2 -1
  96. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  97. package/skills/ultra-chain/SKILL.md +32 -14
  98. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  99. package/skills/ultra-init/SKILL.md +30 -22
  100. package/skills/ultraqa/NOTICE.md +34 -0
  101. package/skills/ultraqa/SKILL.md +197 -0
  102. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  103. package/skills/ultraqa/references/run-folder.mjs +299 -0
  104. package/skills/ultraqa/references/tmux-drive.md +52 -0
  105. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  106. package/skills/update-banker/SKILL.md +1 -1
@@ -16,10 +16,16 @@
16
16
  * Usage:
17
17
  * node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5]
18
18
  * [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project]
19
+ * [--seams] [--slow-clips <ms>]
20
+ * --seams also probe every seam between clips at the first screen size (seamProbe below)
21
+ * --slow-clips hold each clip request this many ms first (a slow network), to catch a poster flicker
22
+ * --seam-reload load the page afresh before each seam, so that every seam meets its clip still on the way
19
23
  * --viewports a trailing t marks a touch screen, r reduced motion (default: the six below)
20
24
  * --header the site's fixed header (default: the engine's .sw-topbar)
21
25
  * --playwright-from-project also look in the current folder's node_modules, before the global npm root (your own project only)
22
- * Exit 0 when nothing failed, 1 otherwise; the report (JSON) goes to --out or stdout.
26
+ * Exit 0 when nothing failed and everything was measured, 1 when something failed, 3 when nothing failed
27
+ * but --seams could not measure a poster at some seam (posterUnmeasured: no pass); the report (JSON)
28
+ * goes to --out or stdout.
23
29
  *
24
30
  * Needs Playwright with Chromium (setup-playwright skill: npx playwright install chromium), found
25
31
  * beside this script or in the global npm root, and in the project only when asked.
@@ -29,6 +35,7 @@ import fs from 'node:fs';
29
35
  import { createRequire } from 'node:module';
30
36
  import os from 'node:os';
31
37
  import path from 'node:path';
38
+ import zlib from 'node:zlib';
32
39
  import { fileURLToPath, pathToFileURL } from 'node:url';
33
40
 
34
41
  export const VIEWPORTS = [
@@ -79,19 +86,39 @@ function pixelContrast(textRgb, alpha, bg) {
79
86
  * image `width` wide. NaN when the rect holds no pixel, so it never passes as a contrast.
80
87
  */
81
88
  export function worstContrast(textRgb, px, width, rect, alpha = 1) {
89
+ return worstPixel(textRgb, px, width, rect, alpha).min;
90
+ }
91
+
92
+ /** worstContrast and the pixel that gives it: { min, bg } (bg null when the rect holds no pixel). */
93
+ export function worstPixel(textRgb, px, width, rect, alpha = 1) {
82
94
  const seen = new Map();
83
- let worst = NaN;
95
+ let worst = { min: NaN, bg: null };
84
96
  for (let y = rect.y; y < rect.y + rect.height; y++) {
85
97
  for (let x = rect.x; x < rect.x + rect.width; x++) {
86
98
  const i = (y * width + x) * 4;
87
99
  const key = (px[i] << 16) | (px[i + 1] << 8) | px[i + 2];
88
100
  if (!seen.has(key)) seen.set(key, pixelContrast(textRgb, alpha, [px[i], px[i + 1], px[i + 2]]));
89
- worst = Number.isNaN(worst) ? seen.get(key) : Math.min(worst, seen.get(key));
101
+ if (Number.isNaN(worst.min) || seen.get(key) < worst.min) worst = { min: seen.get(key), bg: [px[i], px[i + 1], px[i + 2]] };
90
102
  }
91
103
  }
92
104
  return worst;
93
105
  }
94
106
 
107
+ /**
108
+ * The lowest panel alpha (0.01 steps) at which text keeps `min` contrast over one captured pixel: the
109
+ * scene pixel under the panel is recovered from the capture (panel colour at `panelAlpha` over it), then
110
+ * covered again at each alpha. null when no alpha reaches `min`.
111
+ */
112
+ export function alphaNeeded(textRgb, textAlpha, captured, panelRgb, panelAlpha, min) {
113
+ const a = Math.min(0.99, Math.max(0, panelAlpha));
114
+ const scene = captured.map((c, k) => Math.min(255, Math.max(0, (c - a * panelRgb[k]) / (1 - a))));
115
+ for (let n = 0; n <= 100; n++) {
116
+ const bg = scene.map((c, k) => (n / 100) * panelRgb[k] + (1 - n / 100) * c);
117
+ if (pixelContrast(textRgb, textAlpha, bg) >= min) return n / 100;
118
+ }
119
+ return null;
120
+ }
121
+
95
122
  /**
96
123
  * The contrast of a button's text on its own fill: { ratio }. { ratio: null, why } for a colour it
97
124
  * cannot read, or a fill that is not solid (the page shows through): not measured, never passed.
@@ -108,6 +135,9 @@ export const intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width &
108
135
  // Layout positions are fractional (81.3 against 81): one pixel of slack.
109
136
  export const insideViewport = (r, vw, vh, slack = 1) => r.x >= -slack && r.y >= -slack && r.x + r.width <= vw + slack && r.y + r.height <= vh + slack;
110
137
 
138
+ // What a failed panel line needs: the lowest --intro-panel-alpha that lifts its worst pixel to the floor.
139
+ const needs = (c) => (c.needAlpha === undefined ? '' : c.needAlpha === null ? ' (no panel alpha reaches it)' : ` (needs --intro-panel-alpha >= ${c.needAlpha})`);
140
+
111
141
  /** The failures of one measured scene, in a fixed order. */
112
142
  export function judge(s, { minContrast }) {
113
143
  const out = [];
@@ -117,7 +147,7 @@ export function judge(s, { minContrast }) {
117
147
  if (s.hOverflow) out.push('horizontal overflow');
118
148
  for (const c of s.contrast) {
119
149
  if (c.min === null) out.push(`contrast ${c.el} line ${c.line}: not measured (${c.why})`);
120
- else if (!(c.min >= minContrast)) out.push(`contrast ${c.el} line ${c.line}: ${c.min}`);
150
+ else if (!(c.min >= minContrast)) out.push(`contrast ${c.el} line ${c.line}: ${c.min}${needs(c)}`);
121
151
  }
122
152
  if (s.clip === 'not shown') out.push('clip not shown');
123
153
  return out;
@@ -166,7 +196,7 @@ export async function loadPlaywright({ fromProject = false, importBeside = () =>
166
196
 
167
197
  const navInfo = (page) => page.evaluate(() => {
168
198
  const c = window.IntroStepNav && window.IntroStepNav.current;
169
- return c ? { last: c.lastScene, stops: c.stops(), state: c.state(), end: c.end } : null;
199
+ return c ? { last: c.lastScene, lastStop: c.lastStop ?? c.lastScene, stops: c.stops(), state: c.state(), end: c.end } : null;
170
200
  });
171
201
 
172
202
  // Waits until no move runs and the scroll position has held still for 250 ms.
@@ -318,11 +348,12 @@ export async function screenshot(page, opts, { tries = 3, waitMs = 150 } = {}) {
318
348
  }
319
349
  }
320
350
 
321
- async function lineContrast(page, i, panel) {
351
+ async function lineContrast(page, i, panel, minContrast = 4.5, captureMs = 10_000) {
322
352
  const clip = { x: Math.max(0, Math.floor(panel.x)), y: Math.max(0, Math.floor(panel.y)), width: Math.ceil(panel.width), height: Math.ceil(panel.height) };
323
353
  const lines = await page.evaluate(hideTextLines, { i, parts: TEXT_PARTS });
354
+ const panelBg = await page.evaluate((k) => getComputedStyle(document.querySelectorAll('.sw-copy')[k]).backgroundColor, i);
324
355
  await page.waitForTimeout(80);
325
- const png = await screenshot(page, { type: 'png', clip });
356
+ const png = await screenshot(page, { type: 'png', clip, timeout: captureMs });
326
357
  await page.evaluate(showText, { i, parts: TEXT_PARTS });
327
358
  const img = await pixels(page.context(), png);
328
359
  return lines.map((l) => {
@@ -332,15 +363,29 @@ async function lineContrast(page, i, panel) {
332
363
  const rgb = parseColor(l.color);
333
364
  if (!rgb) return { el: l.el, line: l.line, min: null, why: `colour ${l.color}` };
334
365
  if (rect.width < 1 || rect.height < 1) return { el: l.el, line: l.line, min: null, why: 'line outside the panel' };
335
- return { el: l.el, line: l.line, min: Number(worstContrast(rgb, img.data, img.width, rect, colorAlpha(l.color)).toFixed(2)) };
366
+ const w = worstPixel(rgb, img.data, img.width, rect, colorAlpha(l.color));
367
+ return { el: l.el, line: l.line, min: Number(w.min.toFixed(2)), ...panelNeed(l, rgb, w, panelBg, minContrast) };
336
368
  });
337
369
  }
338
370
 
371
+ // For a panel line (not a CTA button with its own fill) on a see-through panel: the alpha it needs.
372
+ function panelNeed(l, rgb, w, panelBg, minContrast) {
373
+ const panel = parseColor(panelBg);
374
+ const a = colorAlpha(panelBg);
375
+ if (l.el === 'cta' || !panel || !w.bg || a <= 0 || a >= 1) return {};
376
+ return { needAlpha: alphaNeeded(rgb, colorAlpha(l.color), w.bg, panel, a, minContrast) };
377
+ }
378
+
339
379
  async function measureScene(page, i, o) {
340
380
  await goToScene(page, i);
381
+ // with step navigation the copy of the scene at its stop fades in (step-nav.css, 0.35 s)
382
+ if (await page.evaluate(() => document.documentElement.classList.contains('isn-on'))) await page.waitForTimeout(450);
341
383
  const clip = await clipState(page, i, o.reduce);
342
384
  const g = geometry(await page.evaluate(rawGeometry, { i, header: o.header, covers: COVERS }));
343
- const contrast = o.contrast && g.opacity > 0.5 ? await lineContrast(page, i, g.panel) : [];
385
+ const contrast = o.contrast && g.opacity > 0.5 ? await lineContrast(page, i, g.panel, o.minContrast, o.captureMs).catch((e) => {
386
+ if (!isTimeout(e)) throw e;
387
+ return [{ el: 'panel', line: 0, min: null, why: `screen capture timed out after ${o.captureMs} ms` }]; // not measured: never a pass
388
+ }) : [];
344
389
  const scene = { scene: i + 1, clip, ...g, contrast };
345
390
  return { ...scene, failures: g.opacity > 0.5 ? judge(scene, o) : ['panel not visible at its stop'] };
346
391
  }
@@ -377,15 +422,21 @@ async function notchWithInertia(page, vp) {
377
422
  }
378
423
  }
379
424
 
380
- async function bodyRoundTrip(page, vp, how, last) {
381
- await goToScene(page, last);
425
+ // A wheel notch and an arrow key go half way: two of them make the step from the last scene's end to the body.
426
+ const HALF = new Set(['wheel', 'ArrowDown']);
427
+ async function sendStep(page, vp, how, dir) {
428
+ for (let k = HALF.has(how) ? 2 : 1; k > 0; k--) await send(page, vp, how, dir);
429
+ }
430
+
431
+ async function bodyRoundTrip(page, vp, how, lastStop) {
432
+ await goToScene(page, lastStop);
382
433
  await page.waitForTimeout(400);
383
- await send(page, vp, how, 'down');
434
+ await sendStep(page, vp, how, 'down');
384
435
  await settle(page);
385
436
  const reachesBody = (await navInfo(page)).state.atBody;
386
- await send(page, vp, how, 'up');
437
+ await sendStep(page, vp, how, 'up');
387
438
  await settle(page);
388
- return { reachesBody, upReturns: (await navInfo(page)).state.index === last };
439
+ return { reachesBody, upReturns: (await navInfo(page)).state.index === lastStop };
389
440
  }
390
441
 
391
442
  const ready = (page) => page.evaluate(() => {
@@ -397,9 +448,9 @@ const bodyFocused = (page, end) => page.evaluate((sel) => document.activeElement
397
448
  // The main button: hidden before the last scene, shown once its clip has played to the end (at
398
449
  // once under reduced motion), and a press goes to the body with the focus. The skip link likewise.
399
450
  async function checkButtons(page, info) {
400
- await goToScene(page, info.last - 1);
451
+ await goToScene(page, info.lastStop - 1);
401
452
  const hiddenBefore = !(await ready(page));
402
- await goToScene(page, info.last);
453
+ await goToScene(page, info.lastStop);
403
454
  const shownAtLast = await ready(page);
404
455
  await page.click('.isn-main');
405
456
  await settle(page);
@@ -420,8 +471,8 @@ async function checkInput(page, vp) {
420
471
  await settle(page);
421
472
  out.inertiaStopsAtLast = (await navInfo(page)).state.index === info.last;
422
473
  }
423
- if (info.stops.length - 1 > info.last) {
424
- for (const how of vp.touch ? ['swipe'] : ['wheel', 'ArrowDown', 'PageDown', 'Space']) out[how] = await bodyRoundTrip(page, vp, how, info.last);
474
+ if (info.stops.length - 1 > info.lastStop) {
475
+ for (const how of vp.touch ? ['swipe'] : ['wheel', 'ArrowDown', 'PageDown', 'Space']) out[how] = await bodyRoundTrip(page, vp, how, info.lastStop);
425
476
  Object.assign(out, await checkButtons(page, info));
426
477
  }
427
478
  return out;
@@ -438,7 +489,9 @@ async function measureViewport(browser, url, vp, o) {
438
489
  const consoleErrors = [];
439
490
  page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
440
491
  page.on('pageerror', (e) => consoleErrors.push(String(e.message || e)));
441
- await page.goto(url, { waitUntil: 'load' });
492
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
493
+ // a page whose load does not come within loadMs is measured as it stands, and that is a failure of this screen
494
+ const loadFailures = await loadedWithin(page, o.loadMs) ? [] : [`page load did not finish within ${o.loadMs} ms (measured while still loading)`];
442
495
  await page.waitForSelector('.sw-copy');
443
496
  const scenes = [];
444
497
  for (let i = 0, n = await page.locator('.sw-copy').count(); i < n; i++) scenes.push(await measureScene(page, i, { ...o, reduce: Boolean(vp.reduce) }));
@@ -446,24 +499,310 @@ async function measureViewport(browser, url, vp, o) {
446
499
  const buttons = o.contrast ? await topCtaContrast(page) : [];
447
500
  await context.close();
448
501
  const buttonMisses = buttonFailures(buttons, o.minContrast);
449
- const failures = scenes.reduce((a, s) => a + s.failures.length, 0) + consoleErrors.length + inputFailures(input).length + buttonMisses.length;
502
+ const failures = scenes.reduce((a, s) => a + s.failures.length, 0) + consoleErrors.length + inputFailures(input).length + buttonMisses.length + loadFailures.length;
450
503
  return { viewport: `${vp.width}x${vp.height}${vp.touch ? ' touch' : ''}${vp.reduce ? ' reduced-motion' : ''}`, scenes, input, inputFailures: inputFailures(input),
451
- buttons, buttonFailures: buttonMisses, consoleErrors, failures };
504
+ buttons, buttonFailures: buttonMisses, consoleErrors, loadFailures, failures };
452
505
  }
453
506
 
454
- /** Measures `url` at every viewport. Resolves to { url, viewports: [...], failures }. */
455
- export async function verifyIntro(url, { viewports = VIEWPORTS, header = '.sw-topbar', minContrast = 4.5, input = true, contrast = true, playwrightFromProject = false } = {}) {
456
- const { chromium } = await loadPlaywright({ fromProject: playwrightFromProject });
507
+ /**
508
+ * Measures `url` at every viewport. Resolves to { url, viewports: [...], failures }; with `seams`,
509
+ * also { seams } from seamProbe at the first viewport, its failures counted in.
510
+ */
511
+ // captureMs, loadMs, fetchMs: the longest wait for one screen capture, the page's load and intro.js; past them that
512
+ // part is not measured (with the reason, never a pass) and the run goes on. A font or an image that never answers
513
+ // would otherwise hold a capture 30 s and throw away every result.
514
+ const VERIFY_DEFAULTS = { viewports: VIEWPORTS, header: '.sw-topbar', minContrast: 4.5, input: true, contrast: true, playwrightFromProject: false, seams: false, slowClipsMs: 0, seamReload: false,
515
+ captureMs: 10_000, loadMs: 15_000, fetchMs: 15_000 };
516
+ const isTimeout = (e) => e?.name === 'TimeoutError';
517
+
518
+ export async function verifyIntro(url, opts = {}) {
519
+ const o = { ...VERIFY_DEFAULTS, ...Object.fromEntries(Object.entries(opts).filter(([, v]) => v !== undefined)) };
520
+ const { chromium } = await loadPlaywright({ fromProject: o.playwrightFromProject });
457
521
  const browser = await chromium.launch();
458
522
  try {
459
523
  const results = [];
460
- for (const vp of viewports) results.push(await measureViewport(browser, url, vp, { header, minContrast, input, contrast }));
461
- return { url, viewports: results, failures: results.reduce((a, r) => a + r.failures, 0) };
524
+ const limits = { captureMs: o.captureMs, loadMs: o.loadMs };
525
+ for (const vp of o.viewports) results.push(await measureViewport(browser, url, vp, { header: o.header, minContrast: o.minContrast, input: o.input, contrast: o.contrast, ...limits }));
526
+ const seam = o.seams ? await seamProbe(browser, url, { viewport: o.viewports[0], slowClipsMs: Number(o.slowClipsMs) || 0, reload: Boolean(o.seamReload), ...limits, fetchMs: o.fetchMs }) : null;
527
+ const failures = results.reduce((a, r) => a + r.failures, 0) + (seam ? seam.failures.length : 0);
528
+ return { url, viewports: results, ...(seam ? { seams: seam } : {}), failures };
462
529
  } finally {
463
530
  await browser.close();
464
531
  }
465
532
  }
466
533
 
534
+ // ---- seam probe (--seams) ----------------------------------------------------------
535
+
536
+ /**
537
+ * steps: captures per seam. windowVh: how far past the seam the probe looks (before it, at least the
538
+ * blank limit plus 0.4 vh). blankVh + the plate share: the longest run of a near one-colour screen a
539
+ * seam may show (a landing on a background plate is one colour for its last frames, so each seam's
540
+ * limit adds plateSec of the clip before it, in vh). flickerDiff: the mean pixel difference (0-255)
541
+ * between the poster a scene shows before its clip paints and the clip once it does, past which the
542
+ * poster is a flicker. settleMs: the longest wait for clips to catch up with the scroll. posterDecodeMs: the
543
+ * longest wait for the posters in view to decode on arrival; past it the seam is unmeasured (posterDiff null, posterWhy).
544
+ * loadMs: the longest wait for the page's load event (a resource that never answers must not stop the probe).
545
+ */
546
+ export const SEAM_DEFAULTS = { steps: 20, windowVh: 0.6, blankVh: 0.3, plateSec: 0.6, flickerDiff: 24, settleMs: 8000, posterDecodeMs: 5000, loadMs: 15000, captureMs: 10_000, fetchMs: 15_000 };
547
+ const OVERLAYS = ['.sw-copylayer', '.sw-topbar', '.sw-hint', '.sw-route', '.sw-scrollbar', '.isn-hint', '.isn-skip', '.isn-main'];
548
+
549
+ /** The mountScrollWorld config in an assembled intro.js; null when it is not one. */
550
+ export function configFromIntroJs(text) {
551
+ const m = /var config = ([\s\S]*?);\n\s*(?:var nav|mountScrollWorld|if \()/.exec(String(text));
552
+ try { return m ? JSON.parse(m[1]) : null; } catch { return null; }
553
+ }
554
+
555
+ /** The engine's segments in scroll order (as scrub-engine.js lays them out), start and end in vh. */
556
+ export function segmentsOf(config) {
557
+ const out = [];
558
+ let off = 0;
559
+ const add = (label, w) => { out.push({ label, start: off, end: off + w }); off += w; };
560
+ (config.sections || []).forEach((s, i, all) => {
561
+ add(`dive ${i + 1}`, s.scroll || config.diveScroll || 1.3);
562
+ if (i < all.length - 1 && (config.connectors || [])[i]) add(`conn ${i + 1}`, config.connScroll || 0.9);
563
+ });
564
+ return out;
565
+ }
566
+
567
+ /** Mean absolute difference of two RGBA pixel arrays over R, G and B (0-255). */
568
+ export function meanAbsDiff(a, b) {
569
+ let sum = 0;
570
+ for (let i = 0; i < a.length; i += 4) sum += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
571
+ return Math.round((sum / ((a.length / 4) * 3)) * 100) / 100;
572
+ }
573
+
574
+ /** A near one-colour screen, whatever the colour: `share` of the pixels within `tolerance` of the most common colour. */
575
+ export function isBlank(data, { tolerance = 10, share = 0.99 } = {}) {
576
+ const ref = [0, 1, 2].map((c) => modeOf(data, c));
577
+ let near = 0;
578
+ for (let i = 0; i < data.length; i += 4) {
579
+ if (Math.abs(data[i] - ref[0]) <= tolerance && Math.abs(data[i + 1] - ref[1]) <= tolerance && Math.abs(data[i + 2] - ref[2]) <= tolerance) near++;
580
+ }
581
+ return near / (data.length / 4) >= share;
582
+ }
583
+
584
+ function modeOf(data, c) {
585
+ const hist = new Uint32Array(256);
586
+ for (let i = c; i < data.length; i += 4) hist[data[i]]++;
587
+ return hist.indexOf(Math.max(...hist));
588
+ }
589
+
590
+ /** Runs of blank captures longer than limitVh: [{ fromVh, toVh, lengthVh }] (ys are the captures' scroll positions in vh). */
591
+ export function blankRuns(flags, ys, limitVh) {
592
+ const step = ys.length > 1 ? ys[1] - ys[0] : 0;
593
+ const runs = [];
594
+ let from = -1;
595
+ flags.concat(false).forEach((f, i) => {
596
+ if (f && from < 0) from = i;
597
+ if (!f && from >= 0) { runs.push({ fromVh: ys[from], toVh: ys[i - 1], lengthVh: ys[i - 1] - ys[from] + step }); from = -1; }
598
+ });
599
+ const r2 = (x) => Math.round(x * 100) / 100;
600
+ return runs.filter((r) => r.lengthVh > limitVh).map((r) => ({ fromVh: r2(r.fromVh), toVh: r2(r.toVh), lengthVh: r2(r.lengthVh) }));
601
+ }
602
+
603
+ // Waits until the clips of the scenes in view are loaded and not seeking, and their frames hold for six
604
+ // animation frames: the engine eases each clip towards the scroll target over several frames. A scene
605
+ // with no video yet (its clip still on the way) or one that cannot show a frame yet is pending; a loaded
606
+ // clip the engine has not needed to seek (a scene at its very first frame) is not.
607
+ const settleClips = (page, limit) => page.evaluate(async (ms) => {
608
+ const t0 = performance.now();
609
+ let last = '';
610
+ let same = 0;
611
+ const waiting = (e) => { const v = e.querySelector('video'); return v ? v.seeking || v.readyState < 2 : !e.classList.contains('has-clip'); };
612
+ while (performance.now() - t0 < ms) {
613
+ await new Promise((r) => requestAnimationFrame(r));
614
+ const seen = [...document.querySelectorAll('.sw-scene')].filter((e) => Number(getComputedStyle(e).opacity) > 0.5);
615
+ const pending = seen.some(waiting);
616
+ const sig = seen.map((e) => e.querySelector('video')?.currentTime.toFixed(3) ?? '-').join(',');
617
+ same = !pending && sig === last ? same + 1 : 0;
618
+ last = sig;
619
+ if (same >= 6) return true;
620
+ }
621
+ return false;
622
+ }, limit);
623
+
624
+ // Whether a scene in view still shows its poster (no clip painted yet).
625
+ const posterShown = (page) => page.evaluate(() => [...document.querySelectorAll('.sw-scene')]
626
+ .some((e) => Number(getComputedStyle(e).opacity) > 0.5 && !e.classList.contains('has-clip')));
627
+
628
+ const scrollToVh = (page, vh) => page.evaluate((y) => { window.scrollTo(0, y * window.innerHeight); return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); }, vh);
629
+ // A seam capture, decoded here: many captures per seam, so not through a browser tab as pixels() does.
630
+ const capture = async (page, ms = SEAM_DEFAULTS.captureMs) => decodePng(await screenshot(page, { type: 'png', timeout: ms })).data;
631
+
632
+ /**
633
+ * RGBA pixels of an 8-bit, non-interlaced PNG (grey, grey+alpha, RGB or RGBA: what a browser
634
+ * screenshot is), decoded with zlib. Throws on any other PNG.
635
+ */
636
+ export function decodePng(buf) {
637
+ if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('decodePng: not a PNG');
638
+ const { width, height, type, depth, interlace, idat } = pngChunks(buf);
639
+ const ch = { 0: 1, 2: 3, 4: 2, 6: 4 }[type];
640
+ if (depth !== 8 || interlace !== 0 || !ch) throw new Error(`decodePng: only 8-bit non-interlaced PNG (got depth ${depth}, type ${type}, interlace ${interlace})`);
641
+ const raw = unfilter(zlib.inflateSync(idat), width, height, ch);
642
+ const data = new Uint8ClampedArray(width * height * 4);
643
+ for (let i = 0, j = 0; i < width * height; i++, j += ch) {
644
+ const grey = ch < 3;
645
+ data.set([raw[j], raw[grey ? j : j + 1], raw[grey ? j : j + 2], ch === 2 || ch === 4 ? raw[j + ch - 1] : 255], i * 4);
646
+ }
647
+ return { width, height, data };
648
+ }
649
+
650
+ function pngChunks(buf) {
651
+ const out = { idat: [] };
652
+ for (let at = 8; at < buf.length;) {
653
+ const len = buf.readUInt32BE(at);
654
+ const kind = buf.toString('latin1', at + 4, at + 8);
655
+ const body = buf.subarray(at + 8, at + 8 + len);
656
+ if (kind === 'IHDR') Object.assign(out, { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], type: body[9], interlace: body[12] });
657
+ if (kind === 'IDAT') out.idat.push(body);
658
+ at += 12 + len;
659
+ }
660
+ return { ...out, idat: Buffer.concat(out.idat) };
661
+ }
662
+
663
+ // Undo the PNG row filters (None, Sub, Up, Average, Paeth).
664
+ function unfilter(src, width, height, bpp) {
665
+ const stride = width * bpp;
666
+ const out = Buffer.alloc(stride * height);
667
+ for (let y = 0; y < height; y++) {
668
+ const f = src[y * (stride + 1)];
669
+ const row = src.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
670
+ for (let x = 0; x < stride; x++) {
671
+ const a = x >= bpp ? out[y * stride + x - bpp] : 0;
672
+ const b = y > 0 ? out[(y - 1) * stride + x] : 0;
673
+ const c = x >= bpp && y > 0 ? out[(y - 1) * stride + x - bpp] : 0;
674
+ out[y * stride + x] = (row[x] + predict(f, a, b, c)) & 0xff;
675
+ }
676
+ }
677
+ return out;
678
+ }
679
+
680
+ function predict(f, a, b, c) {
681
+ if (f === 1) return a;
682
+ if (f === 2) return b;
683
+ if (f === 3) return (a + b) >> 1;
684
+ if (f !== 4) return 0;
685
+ const p = a + b - c;
686
+ const pa = Math.abs(p - a);
687
+ const pb = Math.abs(p - b);
688
+ const pc = Math.abs(p - c);
689
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
690
+ }
691
+
692
+ // The clip length of the segment before a seam, from its video (8 s when it is not loaded).
693
+ const clipSeconds = (page, k) => page.evaluate((i) => document.querySelectorAll('.sw-scene')[i]?.querySelector('video')?.duration || 8, k);
694
+
695
+ /**
696
+ * Scrolls across each seam between clips and measures it: the largest difference between successive
697
+ * captures (maxDiff), runs of a near one-colour screen longer than the seam's limit (blank), and whether
698
+ * a poster that showed on arrival differs from the clip once it paints (posterFlicker). Overlays (copy,
699
+ * bars, dots) are hidden while measuring. Each capture waits for the clips to catch up with the scroll.
700
+ */
701
+ export async function seamProbe(browser, url, { viewport = VIEWPORTS[0], slowClipsMs = 0, ...o } = {}) {
702
+ const opts = { ...SEAM_DEFAULTS, ...o };
703
+ const config = await fetch(new URL('intro.js', url), { signal: AbortSignal.timeout(opts.fetchMs) }).then((res) => res.text()).then(configFromIntroJs).catch(() => null);
704
+ if (!config) return { viewport: `${viewport.width}x${viewport.height}`, boundaries: [], failures: ['seams: intro.js was not read (no answer within fetchMs, or no config in it)'] };
705
+ const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1 });
706
+ const page = await context.newPage();
707
+ if (slowClipsMs) await page.route(/\.mp4(\?|$)/, async (route) => { await new Promise((r) => setTimeout(r, slowClipsMs)); await route.continue(); });
708
+ const loadMisses = [];
709
+ const note = (loaded, at) => { if (!loaded) loadMisses.push(`seams: page load did not finish within ${opts.loadMs} ms ${at} (measured while still loading)`); };
710
+ note(await openIntro(page, url, opts.loadMs), 'at the start');
711
+ const segs = segmentsOf(config);
712
+ const boundaries = [];
713
+ for (let k = 1; k < segs.length; k++) {
714
+ if (opts.reload && k > 1) note(await openIntro(page, url, opts.loadMs), `before ${segs[k - 1].label} -> ${segs[k].label}`);
715
+ boundaries.push(await probeSeam(page, segs, k, opts).catch((e) => {
716
+ if (!isTimeout(e)) throw e;
717
+ const why = `screen capture timed out after ${opts.captureMs} ms`; // not measured: never a pass
718
+ return { from: segs[k - 1].label, to: segs[k].label, atVh: segs[k].start, maxDiff: null, blank: [], posterDiff: null, posterWhy: why,
719
+ failures: [`seam ${segs[k - 1].label} -> ${segs[k].label}: not measured (${why})`] };
720
+ }));
721
+ }
722
+ await context.close();
723
+ // a seam whose clip was there on arrival showed no poster: not measured, so not counted as a pass
724
+ const posterUnmeasured = boundaries.filter((b) => b.posterDiff === null).length;
725
+ return { viewport: `${viewport.width}x${viewport.height}`, slowClipsMs, reload: Boolean(opts.reload), posterMeasured: boundaries.length - posterUnmeasured, posterUnmeasured, boundaries, failures: [...loadMisses, ...boundaries.flatMap((b) => b.failures)] };
726
+ }
727
+
728
+ // Whether the page's load event came within ms (a resource whose answer never comes must not stop a measure).
729
+ const loadedWithin = (page, ms) => page.waitForLoadState('load', { timeout: ms }).then(() => true, () => false);
730
+
731
+ // Opens the intro for the seam probe; false when its load did not come within loadMs (the caller fails it).
732
+ // Routing (the slow clips) turns the browser's HTTP cache off, so a fresh load fetches every clip again.
733
+ async function openIntro(page, url, loadMs = SEAM_DEFAULTS.loadMs) {
734
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
735
+ const loaded = await loadedWithin(page, loadMs);
736
+ await page.waitForSelector('.sw-scene');
737
+ // the probe parks the page between stops: step navigation must not settle it on one
738
+ await page.evaluate(() => window.IntroStepNav?.current?.hold(true));
739
+ await page.evaluate((sels) => { for (const el of document.querySelectorAll(sels.join(','))) el.style.visibility = 'hidden'; }, OVERLAYS);
740
+ return loaded;
741
+ }
742
+
743
+ // The scenes in view: whether each shows its clip, its poster's load state and transform, its video's frame.
744
+ const sceneStates = (page) => page.evaluate(() => [...document.querySelectorAll('.sw-scene')].map((e, scene) => ({ e, scene }))
745
+ .filter(({ e }) => Number(getComputedStyle(e).opacity) > 0.5).map(({ e, scene }) => {
746
+ const img = e.querySelector('.sw-scene__still');
747
+ const v = e.querySelector('video');
748
+ return { scene, hasClip: e.classList.contains('has-clip'),
749
+ poster: img ? { complete: img.complete, naturalWidth: img.naturalWidth, transform: getComputedStyle(img).transform } : null,
750
+ video: v ? { readyState: v.readyState, currentTime: Number(v.currentTime.toFixed(3)) } : null };
751
+ }));
752
+
753
+ async function probeSeam(page, segs, k, o) {
754
+ const at = segs[k].start;
755
+ const prev = segs[k - 1];
756
+ const before = Math.max(o.windowVh, o.blankVh + 0.4 + (o.plateSec / 2) * (prev.end - prev.start));
757
+ const arrival = await arrive(page, at + 0.02, o);
758
+ const limit = o.blankVh + (o.plateSec / (await clipSeconds(page, k - 1))) * (prev.end - prev.start);
759
+ const ys = Array.from({ length: o.steps + 1 }, (_, i) => at - before + ((before + o.windowVh) * i) / o.steps);
760
+ const flags = [];
761
+ let maxDiff = 0;
762
+ let last = null;
763
+ for (const y of ys) {
764
+ await scrollToVh(page, y);
765
+ await settleClips(page, o.settleMs);
766
+ const px = await capture(page, o.captureMs);
767
+ if (last) maxDiff = Math.max(maxDiff, meanAbsDiff(last, px));
768
+ flags.push(isBlank(px));
769
+ last = px;
770
+ }
771
+ const blank = blankRuns(flags, ys, limit);
772
+ const failures = [
773
+ ...blank.map((b) => `seam ${prev.label} -> ${segs[k].label}: a near one-colour screen for ${b.lengthVh} vh (limit ${Math.round(limit * 100) / 100} vh)`),
774
+ ...(arrival.flicker ? [`seam ${prev.label} -> ${segs[k].label}: the poster differs from the clip by ${arrival.diff} (limit ${o.flickerDiff})`] : []),
775
+ ];
776
+ return { from: prev.label, to: segs[k].label, atVh: at, maxDiff, blank, blankLimitVh: Math.round(limit * 100) / 100, posterDiff: arrival.diff,
777
+ ...(arrival.why ? { posterWhy: arrival.why } : {}), arrival: arrival.states, failures };
778
+ }
779
+
780
+ // Jump just past the seam, where the entering clip is at its first frames; when a poster shows there,
781
+ // compare it with the clip once it paints. A poster that is the clip's own first frame barely differs.
782
+ async function arrive(page, vh, o) {
783
+ await scrollToVh(page, vh);
784
+ // a scene shown by the jump paints nothing until its poster is decoded (0.1-0.35 s seen): wait for that, so the
785
+ // capture is the poster, not the empty frame a jump shows (the empty frame is another matter than the flicker)
786
+ // at most posterDecodeMs: a poster whose answer never comes leaves this seam unmeasured, with the reason
787
+ const decoded = await page.evaluate((ms) => Promise.race([
788
+ Promise.all([...document.querySelectorAll('.sw-scene')].filter((e) => Number(getComputedStyle(e).opacity) > 0.5)
789
+ .map((e) => e.querySelector('.sw-scene__still')?.decode().catch(() => {}))).then(() => true),
790
+ new Promise((r) => setTimeout(() => r(false), ms)),
791
+ ]).then((ok) => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(ok))))), o.posterDecodeMs);
792
+ const before = await sceneStates(page);
793
+ if (!decoded) return { diff: null, flicker: false, why: `a poster did not decode within ${o.posterDecodeMs} ms`, states: { before } };
794
+ if (!(await posterShown(page))) { await settleClips(page, o.settleMs); return { diff: null, flicker: false, states: { before } }; }
795
+ const early = await capture(page, o.captureMs);
796
+ await page.waitForFunction(() => [...document.querySelectorAll('.sw-scene')]
797
+ .every((e) => Number(getComputedStyle(e).opacity) <= 0.5 || e.classList.contains('has-clip')), null, { timeout: o.settleMs }).catch(() => {});
798
+ await settleClips(page, o.settleMs);
799
+ const diff = meanAbsDiff(early, await capture(page, o.captureMs));
800
+ return { diff, flicker: diff > o.flickerDiff, states: { before, after: await sceneStates(page) } };
801
+ }
802
+
803
+ /** The CLI's exit code: 1 on a failure, 3 when none failed but a seam's poster went unmeasured, else 0. */
804
+ export const exitCode = (report) => (report.failures ? 1 : report.seams?.posterUnmeasured ? 3 : 0);
805
+
467
806
  // '390x844t' is a touch screen, '1280x800r' reduced motion; the flags may combine ('390x844tr').
468
807
  export function parseViewport(v) {
469
808
  const m = /^(\d+)x(\d+)([tr]*)$/.exec(v.trim());
@@ -471,12 +810,12 @@ export function parseViewport(v) {
471
810
  return { width: Number(m[1]), height: Number(m[2]), touch: m[3].includes('t'), reduce: m[3].includes('r') };
472
811
  }
473
812
 
474
- const FLAGS = { '--out': 'out', '--header': 'header', '--min-contrast': 'minContrast', '--viewports': 'viewports' };
475
- const SWITCHES = { '--no-input': ['input', false], '--no-contrast': ['contrast', false], '--playwright-from-project': ['playwrightFromProject', true] };
813
+ const FLAGS = { '--out': 'out', '--header': 'header', '--min-contrast': 'minContrast', '--viewports': 'viewports', '--slow-clips': 'slowClipsMs' };
814
+ const SWITCHES = { '--no-input': ['input', false], '--no-contrast': ['contrast', false], '--playwright-from-project': ['playwrightFromProject', true], '--seams': ['seams', true], '--seam-reload': ['seamReload', true] };
476
815
 
477
816
  /** The command line: a URL, --flag value or --flag=value, and the switches. */
478
817
  export function parseArgs(argv) {
479
- const o = { url: null, out: null, header: '.sw-topbar', minContrast: 4.5, viewports: VIEWPORTS, input: true, contrast: true, playwrightFromProject: false };
818
+ const o = { url: null, out: null, header: '.sw-topbar', minContrast: 4.5, viewports: VIEWPORTS, input: true, contrast: true, playwrightFromProject: false, seams: false, slowClipsMs: 0, seamReload: false };
480
819
  for (let i = 0; i < argv.length; i++) {
481
820
  const [flag, inline] = argv[i].includes('=') ? [argv[i].slice(0, argv[i].indexOf('=')), argv[i].slice(argv[i].indexOf('=') + 1)] : [argv[i], null];
482
821
  if (SWITCHES[flag]) o[SWITCHES[flag][0]] = SWITCHES[flag][1];
@@ -485,6 +824,7 @@ export function parseArgs(argv) {
485
824
  else o.url = argv[i];
486
825
  }
487
826
  o.minContrast = Number(o.minContrast);
827
+ o.slowClipsMs = Number(o.slowClipsMs) || 0;
488
828
  if (typeof o.viewports === 'string') o.viewports = o.viewports.split(',').map(parseViewport);
489
829
  return o;
490
830
  }
@@ -502,14 +842,15 @@ if (invokedDirectly()) {
502
842
  let o;
503
843
  try { o = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
504
844
  if (!o.url) {
505
- console.error('usage: node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5] [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project]');
845
+ console.error('usage: node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5] [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project] [--seams] [--slow-clips <ms>] [--seam-reload]');
506
846
  process.exit(2);
507
847
  }
508
848
  verifyIntro(o.url, o).then((r) => {
509
849
  const text = JSON.stringify(r, null, 2);
510
850
  if (o.out) fs.writeFileSync(o.out, text + '\n');
511
851
  else console.log(text);
512
- console.error(`verify-intro: ${r.failures} failure(s) across ${r.viewports.length} screen size(s)`);
513
- process.exit(r.failures ? 1 : 0);
852
+ const unmeasured = r.seams?.posterUnmeasured || 0;
853
+ console.error(`verify-intro: ${r.failures} failure(s) across ${r.viewports.length} screen size(s)${unmeasured ? `; poster not measured at ${unmeasured} seam(s) (add --slow-clips and --seam-reload)` : ''}`);
854
+ process.exit(exitCode(r));
514
855
  }).catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
515
856
  }
@@ -9,6 +9,7 @@ Node 용 playwright 패키지와 Chromium 이 필요하다(`npm i -g playwright
9
9
  ```bash
10
10
  node references/verify-intro.mjs http://localhost:<port>/ --out verify.json
11
11
  node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800r --out verify-reduced.json # 움직임 줄이기
12
+ node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800 --no-input --seams --slow-clips 4000 --out verify-seams.json # 이음매 탐침(느린 적재 흉내)
12
13
  ```
13
14
 
14
15
  | 항목 | 기준 |
@@ -25,6 +26,7 @@ node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800r
25
26
  | 입력 (본문이 있을 때) | 마지막 장면에서 휠, 아래 방향키, PageDown, Space, 스와이프 1회로 본문 맨 위에 섬. 위 입력 1회로 마지막 장면 복귀 |
26
27
  | 버튼과 링크 | 메인화면 버튼은 마지막 장면 전에는 숨고 도착하면 보임. 누르면 본문으로 가서 포커스가 본문에 감. 건너뛰기 링크도 같음 |
27
28
  | 움직임 줄이기 | 이동 시간 0. 메인화면 버튼이 마지막 장면에서 보임 |
29
+ | 이음매 (`--seams`) | 첫 화면 크기에서 클립 사이 경계마다 앞뒤를 20 단계로 스크롤하며, 클립이 스크롤을 따라잡고 `seeked` 를 마친 뒤 찍는다(문구, 막대, 점은 숨김). 빈 화면(색과 관계없이 거의 한 색인 화면)이 이어지는 길이가 0.3 vh 에 앞 클립의 배경 판 몫(0.6초가 스크롤에서 차지하는 길이)을 더한 한도를 넘으면 실패. 도착할 때 포스터가 보이면 클립이 그려진 뒤와 비교해 평균 픽셀 차이가 24 를 넘으면 포스터 깜빡임 실패. 연속 화면 차이의 최대값(`maxDiff`)은 기록만 한다. `--slow-clips <ms>` 는 클립 요청마다 그만큼 늦춰 느린 망을 흉내 낸다 |
28
30
 
29
31
  ## 2. 눈으로 보는 항목
30
32
 
@@ -36,6 +38,10 @@ node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800r
36
38
  | 화면 속 글자 | 깨진 글자, 뜻 없는 글자가 없음. 있으면 그 스틸이나 클립만 재생성 |
37
39
  | 사물 | 사물이 엉뚱한 위치에 뜨거나 이상하게 움직이지 않음. 색이 다른 물체로 번지지 않음 |
38
40
  | 이음매 | chain 은 경미한 톤 drift 까지 허용. holdFlight 는 비행 클립의 첫과 끝 프레임이 장면과 맞음 |
41
+ | 배경 이음매 (dive) | 배경 판 착지가 켜진 이음매는 이음매 쪽 클립(나가기, 없으면 dive)의 끝이 판 한 색으로 끝나고, 다음 장면이 같은 바탕 위에서 시작함. 판 색이 다음 장면 바탕과 눈에 띄게 다르면 지적. 착지가 꺼진 이음매(테두리가 한 색이 아닌 스틸)는 엔진 디졸브만 보임 |
42
+ | 회전 (dive arc) | `arc` 장면은 카메라가 초점 대상을 가운데 두고 실제로 돌며 내려가는 것이 보임(대략 4분의 1 바퀴). 줌만 보이고 돌지 않으면 지적. `cameraMotion` 의 roll 근사(누적 회전각)는 참고값이다. 평행 이동은 재지 않고, 대상 둘레를 도는 궤도는 화면의 roll 과 다르므로 판정은 눈으로 한다 |
43
+ | 결론 착지 (dive finale) | 마지막 장면 클립이 결론 스틸 그대로의 화면으로 끝남. 뒤집힌 클립이라 사람, 연기, 물의 움직임이 거꾸로 보이면 지적 |
44
+ | 포스터 (dive 나가기) | 느린 망에서도 나가기 구간에 들어설 때 다음 장면이 잠깐 보였다가 이전 장면 실내로 돌아가는 튐이 없음(`connectorStills` 가 그 클립의 첫 프레임을 포스터로 둠) |
39
45
  | 첫 이동 | 첫 장면에서 처음 움직일 때 영상이 바로 나옴(클립 미리 받기) |
40
46
  | 문구 | 375px 화면에서 본문이 넘치지 않음. 유리 패널이면 본문은 한 줄에 항목 하나(권장 세 줄). CTA 는 마지막 장면에만 |
41
47