@kaydash9999/banker-plugins 0.15.2 → 0.16.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +65 -0
- package/README.md +31 -9
- package/codex/manifest.json +6 -3
- package/codex/transform-matrix.md +2 -0
- package/commands/progress.md +10 -0
- package/commands/setup.md +1 -1
- package/hooks/graceful-pause.mjs +31 -71
- package/hooks/hooks.json +11 -1
- package/hooks/progress-fallback.mjs +10 -0
- package/hooks/progress.mjs +427 -0
- package/hooks/register.mjs +82 -5
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +238 -122
- package/skills/3d-intro-build/references/NOTICE.md +2 -1
- package/skills/3d-intro-build/references/assemble.mjs +164 -108
- package/skills/3d-intro-build/references/azure-adapter.mjs +204 -44
- package/skills/3d-intro-build/references/curate.mjs +573 -267
- package/skills/3d-intro-build/references/intro-fixes.css +61 -0
- package/skills/3d-intro-build/references/panel-glass.css +55 -0
- package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
- package/skills/3d-intro-build/references/prompts.md +32 -9
- package/skills/3d-intro-build/references/serve.mjs +111 -103
- package/skills/3d-intro-build/references/step-nav.css +59 -0
- package/skills/3d-intro-build/references/step-nav.js +503 -0
- package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
- package/skills/3d-intro-build/references/verify.md +45 -0
- package/skills/3d-intro-build/references/video-pool.mjs +817 -0
- package/skills/3d-intro-setup/SKILL.md +120 -47
- package/skills/3d-intro-setup/references/azure-adapter.mjs +204 -44
- package/skills/3d-intro-setup/references/video-pool.mjs +817 -0
- package/skills/motion-graphic-make/SKILL.md +73 -28
- package/skills/motion-graphic-make/references/curate.mjs +607 -0
- package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
- package/skills/motion-graphic-make/references/serve.mjs +131 -0
- package/skills/omc-patch/SKILL.md +143 -0
- package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
- package/skills/remains/SKILL.md +125 -0
- package/skills/remains/scripts/boxes.mjs +448 -0
- package/skills/trouble-shooting/SKILL.md +50 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Measures an assembled intro in a real browser, for the independent check of the 3d-intro-build
|
|
4
|
+
* skill: run by a checker other than the one who built the page. Each screen size gets a fresh
|
|
5
|
+
* browser context (a reused one serves old CSS from its cache after a rebuild).
|
|
6
|
+
*
|
|
7
|
+
* Per screen and scene: whether the copy panel is inside the screen and below the header, whether
|
|
8
|
+
* it overlaps a hint, the skip link, the main button or the route dots, horizontal overflow, the
|
|
9
|
+
* contrast of each text line (CTA button labels included) against the worst pixel behind it,
|
|
10
|
+
* measured with the text hidden, and from the second scene on, whether a clip that is there shows.
|
|
11
|
+
* Per screen: console errors (a CSP refusal shows here) and the top-bar CTA's text on its own
|
|
12
|
+
* solid fill. With step navigation (step-nav.js):
|
|
13
|
+
* a wheel notch plus an inertia tail from the scene before the last stops at the last scene, and
|
|
14
|
+
* from the last scene each input kind reaches the body top and one up input returns.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5]
|
|
18
|
+
* [--viewports 1280x800,390x844t] [--no-input] [--no-contrast]
|
|
19
|
+
* --viewports a trailing t marks a touch screen, r reduced motion (default: the six below)
|
|
20
|
+
* --header the site's fixed header (default: the engine's .sw-topbar)
|
|
21
|
+
* Exit 0 when nothing failed, 1 otherwise; the report (JSON) goes to --out or stdout.
|
|
22
|
+
*
|
|
23
|
+
* Needs Playwright with Chromium (setup-playwright skill: npx playwright install chromium), found
|
|
24
|
+
* from this folder or from the global npm root.
|
|
25
|
+
*/
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
export const VIEWPORTS = [
|
|
33
|
+
{ width: 1280, height: 800 }, { width: 1920, height: 1080 }, { width: 860, height: 900 },
|
|
34
|
+
{ width: 390, height: 844, touch: true }, { width: 375, height: 667, touch: true }, { width: 844, height: 390, touch: true },
|
|
35
|
+
];
|
|
36
|
+
// What a copy panel must not cover.
|
|
37
|
+
const COVERS = ['.isn-hint', '.sw-hint', '.isn-skip', '.isn-main', '.sw-route'];
|
|
38
|
+
// The text of a panel measured for contrast: its parts, and the label of each CTA button (against
|
|
39
|
+
// the button, or what shows behind a ghost one).
|
|
40
|
+
export const TEXT_PARTS = [['num', '.sw-copy__num'], ['eyebrow', '.sw-copy__eyebrow'], ['title', '.sw-copy__title'],
|
|
41
|
+
['body', '.sw-copy__body'], ['cta', '.sw-copy__cta .sw-btn']];
|
|
42
|
+
|
|
43
|
+
// ---- measuring logic (tested without a browser) ----------------------------
|
|
44
|
+
|
|
45
|
+
const channel = (c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
|
|
46
|
+
export const relLuminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
47
|
+
export const contrastRatio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
48
|
+
/**
|
|
49
|
+
* [r, g, b] of a computed colour: rgb()/rgba() with commas or spaces, or color(srgb r g b), the form
|
|
50
|
+
* a color-mix() result takes. null for any other form, so it is never taken for black.
|
|
51
|
+
*/
|
|
52
|
+
export function parseColor(s) {
|
|
53
|
+
const t = String(s).trim();
|
|
54
|
+
const rgb = /^rgba?\(([^)]+)\)$/.exec(t);
|
|
55
|
+
if (rgb) return rgb[1].split(/[\s,/]+/).filter(Boolean).slice(0, 3).map(Number);
|
|
56
|
+
const srgb = /^color\(srgb\s+([^)]+)\)$/.exec(t);
|
|
57
|
+
if (srgb) return srgb[1].split(/[\s/]+/).filter(Boolean).slice(0, 3).map((v) => Math.round(Number(v) * 255));
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The alpha of a computed colour (1 when it has none): rgba(), rgb( / a), color(srgb / a), % or 0..1. */
|
|
62
|
+
export function colorAlpha(s) {
|
|
63
|
+
const t = String(s).trim();
|
|
64
|
+
const m = /\/\s*([\d.]+%?)\s*\)$/.exec(t) || (/^rgba\(/.test(t) ? /,\s*([\d.]+%?)\s*\)$/.exec(t) : null);
|
|
65
|
+
if (!m) return 1;
|
|
66
|
+
return m[1].endsWith('%') ? Number(m[1].slice(0, -1)) / 100 : Number(m[1]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The contrast of text (alpha over the pixel, as it is drawn) against one background pixel.
|
|
70
|
+
function pixelContrast(textRgb, alpha, bg) {
|
|
71
|
+
const fg = alpha >= 1 ? textRgb : textRgb.map((c, k) => alpha * c + (1 - alpha) * bg[k]);
|
|
72
|
+
return contrastRatio(relLuminance(fg), relLuminance(bg));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The lowest contrast between the text colour (with its alpha) and any pixel of `rect` in an RGBA
|
|
77
|
+
* image `width` wide. NaN when the rect holds no pixel, so it never passes as a contrast.
|
|
78
|
+
*/
|
|
79
|
+
export function worstContrast(textRgb, px, width, rect, alpha = 1) {
|
|
80
|
+
const seen = new Map();
|
|
81
|
+
let worst = NaN;
|
|
82
|
+
for (let y = rect.y; y < rect.y + rect.height; y++) {
|
|
83
|
+
for (let x = rect.x; x < rect.x + rect.width; x++) {
|
|
84
|
+
const i = (y * width + x) * 4;
|
|
85
|
+
const key = (px[i] << 16) | (px[i + 1] << 8) | px[i + 2];
|
|
86
|
+
if (!seen.has(key)) seen.set(key, pixelContrast(textRgb, alpha, [px[i], px[i + 1], px[i + 2]]));
|
|
87
|
+
worst = Number.isNaN(worst) ? seen.get(key) : Math.min(worst, seen.get(key));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return worst;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The contrast of a button's text on its own fill: { ratio }. { ratio: null, why } for a colour it
|
|
95
|
+
* cannot read, or a fill that is not solid (the page shows through): not measured, never passed.
|
|
96
|
+
*/
|
|
97
|
+
export function fillContrast(color, background) {
|
|
98
|
+
const fg = parseColor(color);
|
|
99
|
+
const bg = parseColor(background);
|
|
100
|
+
if (!fg || !bg) return { ratio: null, why: `colour ${fg ? background : color}` };
|
|
101
|
+
if (colorAlpha(background) < 1) return { ratio: null, why: `fill not solid: ${background}` };
|
|
102
|
+
return { ratio: Number(pixelContrast(fg, colorAlpha(color), bg).toFixed(2)) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const intersects = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
106
|
+
// Layout positions are fractional (81.3 against 81): one pixel of slack.
|
|
107
|
+
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;
|
|
108
|
+
|
|
109
|
+
/** The failures of one measured scene, in a fixed order. */
|
|
110
|
+
export function judge(s, { minContrast }) {
|
|
111
|
+
const out = [];
|
|
112
|
+
if (!s.inViewport) out.push('panel outside the screen');
|
|
113
|
+
if (!s.belowHeader) out.push('panel under the header');
|
|
114
|
+
for (const sel of s.overlaps) out.push(`overlaps ${sel}`);
|
|
115
|
+
if (s.hOverflow) out.push('horizontal overflow');
|
|
116
|
+
for (const c of s.contrast) {
|
|
117
|
+
if (c.min === null) out.push(`contrast ${c.el} line ${c.line}: not measured (${c.why})`);
|
|
118
|
+
else if (!(c.min >= minContrast)) out.push(`contrast ${c.el} line ${c.line}: ${c.min}`);
|
|
119
|
+
}
|
|
120
|
+
if (s.clip === 'not shown') out.push('clip not shown');
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- browser side ------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
// The playwright package resolved from `base` (a folder), or null.
|
|
127
|
+
function resolveFrom(base) {
|
|
128
|
+
try { return createRequire(path.join(base, 'noop.js')).resolve('playwright'); } catch { return null; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The playwright package: beside this script, in the project (the current folder), or in the global
|
|
133
|
+
* npm root. `npx playwright install chromium` alone fetches the browser, not this package.
|
|
134
|
+
*/
|
|
135
|
+
export async function loadPlaywright() {
|
|
136
|
+
try { return await import('playwright'); } catch { /* not beside this script */ }
|
|
137
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
138
|
+
const globalRoot = String(spawnSync(npm, ['root', '-g'], { encoding: 'utf8', shell: process.platform === 'win32' }).stdout || '').trim();
|
|
139
|
+
const entry = resolveFrom(process.cwd()) || (globalRoot && resolveFrom(globalRoot));
|
|
140
|
+
if (entry) {
|
|
141
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
142
|
+
return mod.chromium ? mod : mod.default;
|
|
143
|
+
}
|
|
144
|
+
throw new Error('verify-intro: the playwright package was not found. Install it and its browser: '
|
|
145
|
+
+ '`npm i -g playwright && npx playwright install chromium` (or `npm i -D playwright` in the project).');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const navInfo = (page) => page.evaluate(() => {
|
|
149
|
+
const c = window.IntroStepNav && window.IntroStepNav.current;
|
|
150
|
+
return c ? { last: c.lastScene, stops: c.stops(), state: c.state(), end: c.end } : null;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Waits until no move runs and the scroll position has held still for 250 ms.
|
|
154
|
+
async function settle(page) {
|
|
155
|
+
for (let k = 0; k < 60; k++) {
|
|
156
|
+
const still = await page.evaluate(() => new Promise((resolve) => {
|
|
157
|
+
const nav = window.IntroStepNav && window.IntroStepNav.current;
|
|
158
|
+
const y = window.scrollY;
|
|
159
|
+
setTimeout(() => resolve(window.scrollY === y && !(nav && nav.state().moving)), 250);
|
|
160
|
+
}));
|
|
161
|
+
if (still) return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether the scene in view shows its clip. 'shown': the engine marks a scene has-clip once a real
|
|
167
|
+
* frame of it painted. From the second scene on (5 s): 'not shown' when a video is there but painted
|
|
168
|
+
* nothing (a codec the browser cannot decode, a broken file), a failure; 'none' when there is no
|
|
169
|
+
* video (a scene without a clip; one that failed to load shows in the console). Not judged (3 s, as
|
|
170
|
+
* a settle): the first scene keeps its still until the first move ('poster'), and under reduced
|
|
171
|
+
* motion the engine loads no clips ('off').
|
|
172
|
+
*/
|
|
173
|
+
async function clipState(page, i, reduce) {
|
|
174
|
+
const judged = i > 0 && !reduce;
|
|
175
|
+
try {
|
|
176
|
+
await page.waitForFunction(() => [...document.querySelectorAll('.sw-scene.has-clip')]
|
|
177
|
+
.some((e) => Number(getComputedStyle(e).opacity) > 0.5), null, { timeout: judged ? 5000 : 3000 });
|
|
178
|
+
return 'shown';
|
|
179
|
+
} catch {
|
|
180
|
+
if (!judged) return reduce ? 'off' : 'poster';
|
|
181
|
+
return page.evaluate(() => ([...document.querySelectorAll('.sw-scene')]
|
|
182
|
+
.some((e) => Number(getComputedStyle(e).opacity) > 0.5 && e.querySelector('video')) ? 'not shown' : 'none'));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function goToScene(page, i) {
|
|
187
|
+
const viaNav = await page.evaluate((k) => {
|
|
188
|
+
const nav = window.IntroStepNav && window.IntroStepNav.current;
|
|
189
|
+
if (nav) nav.goTo(k);
|
|
190
|
+
else if (k === 0) window.scrollTo(0, 0);
|
|
191
|
+
else document.querySelectorAll('.sw-route__dot')[k].click();
|
|
192
|
+
return Boolean(nav);
|
|
193
|
+
}, i);
|
|
194
|
+
await settle(page);
|
|
195
|
+
if (!viaNav) await page.waitForTimeout(300);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function rawGeometry({ i, header, covers }) {
|
|
199
|
+
const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; };
|
|
200
|
+
const shown = (e) => {
|
|
201
|
+
const s = getComputedStyle(e);
|
|
202
|
+
return s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.05 && e.getClientRects().length > 0;
|
|
203
|
+
};
|
|
204
|
+
const panel = document.querySelectorAll('.sw-copy')[i];
|
|
205
|
+
const head = header ? document.querySelector(header) : null;
|
|
206
|
+
return {
|
|
207
|
+
opacity: Number(getComputedStyle(panel).opacity), panel: box(panel), vw: innerWidth, vh: innerHeight,
|
|
208
|
+
header: head && shown(head) ? box(head) : null, scrollWidth: document.documentElement.scrollWidth,
|
|
209
|
+
others: covers.flatMap((sel) => [...document.querySelectorAll(sel)].filter(shown).map((e) => ({ sel, r: box(e) }))),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function geometry(raw) {
|
|
214
|
+
return {
|
|
215
|
+
opacity: Number(raw.opacity.toFixed(2)), panel: raw.panel, inViewport: insideViewport(raw.panel, raw.vw, raw.vh),
|
|
216
|
+
belowHeader: !raw.header || raw.panel.y >= raw.header.y + raw.header.height - 1,
|
|
217
|
+
overlaps: raw.others.filter((o) => intersects(raw.panel, o.r)).map((o) => o.sel),
|
|
218
|
+
hOverflow: raw.scrollWidth > raw.vw + 1,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// In the page: the line boxes of each text part of panel `i` (lines numbered per part, across the
|
|
223
|
+
// buttons of the CTA), then the text hidden (CSSOM, which a CSP allows). Returns
|
|
224
|
+
// [{ el, line, color, rect }] in viewport pixels.
|
|
225
|
+
function hideTextLines({ i, parts }) {
|
|
226
|
+
const lineBoxes = (node) => {
|
|
227
|
+
const range = document.createRange();
|
|
228
|
+
range.selectNodeContents(node);
|
|
229
|
+
const lines = new Map();
|
|
230
|
+
for (const r of range.getClientRects()) {
|
|
231
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
232
|
+
const k = Math.round(r.top);
|
|
233
|
+
const u = lines.get(k);
|
|
234
|
+
lines.set(k, u ? { left: Math.min(u.left, r.left), right: Math.max(u.right, r.right), top: u.top, bottom: Math.max(u.bottom, r.bottom) } : { left: r.left, right: r.right, top: r.top, bottom: r.bottom });
|
|
235
|
+
}
|
|
236
|
+
return [...lines.values()].map((r) => ({ x: r.left, y: r.top, width: r.right - r.left, height: r.bottom - r.top }));
|
|
237
|
+
};
|
|
238
|
+
const panel = document.querySelectorAll('.sw-copy')[i];
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const [el, sel] of parts) {
|
|
241
|
+
let n = 0;
|
|
242
|
+
for (const node of panel.querySelectorAll(sel)) {
|
|
243
|
+
for (const rect of lineBoxes(node)) out.push({ el, line: ++n, color: getComputedStyle(node).color, rect });
|
|
244
|
+
// No transition: a site's `a { transition: color }` would leave the label half drawn.
|
|
245
|
+
node.style.setProperty('transition', 'none', 'important');
|
|
246
|
+
node.style.setProperty('color', 'transparent', 'important');
|
|
247
|
+
node.style.setProperty('text-shadow', 'none', 'important');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function showText({ i, parts }) {
|
|
254
|
+
for (const node of document.querySelectorAll('.sw-copy')[i].querySelectorAll(parts.map(([, sel]) => sel).join(', '))) {
|
|
255
|
+
for (const prop of ['color', 'text-shadow', 'transition']) node.style.removeProperty(prop);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The top-bar CTA (manifest `cta`) when shown: its text on its own fill, [{ sel, ratio, why? }].
|
|
260
|
+
async function topCtaContrast(page) {
|
|
261
|
+
const found = await page.evaluate(() => [...document.querySelectorAll('.sw-topcta')].filter((e) => e.getClientRects().length > 0)
|
|
262
|
+
.map((e) => ({ color: getComputedStyle(e).color, background: getComputedStyle(e).backgroundColor })));
|
|
263
|
+
return found.map((c) => ({ sel: '.sw-topcta', ...fillContrast(c.color, c.background) }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Every top-bar CTA below the contrast floor or not measured, as `contrast <sel>: <what>`.
|
|
267
|
+
const buttonFailures = (buttons, minContrast) => buttons.filter((b) => !(b.ratio >= minContrast))
|
|
268
|
+
.map((b) => `contrast ${b.sel}: ${b.ratio === null ? `not measured (${b.why})` : b.ratio}`);
|
|
269
|
+
|
|
270
|
+
// Decodes a PNG in a blank page of the same context (no CSP there) into RGBA pixels.
|
|
271
|
+
async function pixels(context, png) {
|
|
272
|
+
const page = await context.newPage();
|
|
273
|
+
const img = await page.evaluate(async (b64) => {
|
|
274
|
+
const im = new Image();
|
|
275
|
+
im.src = `data:image/png;base64,${b64}`;
|
|
276
|
+
await im.decode();
|
|
277
|
+
const c = document.createElement('canvas');
|
|
278
|
+
c.width = im.width;
|
|
279
|
+
c.height = im.height;
|
|
280
|
+
const g = c.getContext('2d');
|
|
281
|
+
g.drawImage(im, 0, 0);
|
|
282
|
+
return { width: c.width, height: c.height, data: Array.from(g.getImageData(0, 0, c.width, c.height).data) };
|
|
283
|
+
}, png.toString('base64'));
|
|
284
|
+
await page.close();
|
|
285
|
+
return { width: img.width, height: img.height, data: Uint8ClampedArray.from(img.data) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function lineContrast(page, i, panel) {
|
|
289
|
+
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) };
|
|
290
|
+
const lines = await page.evaluate(hideTextLines, { i, parts: TEXT_PARTS });
|
|
291
|
+
await page.waitForTimeout(80);
|
|
292
|
+
const png = await page.screenshot({ type: 'png', clip });
|
|
293
|
+
await page.evaluate(showText, { i, parts: TEXT_PARTS });
|
|
294
|
+
const img = await pixels(page.context(), png);
|
|
295
|
+
return lines.map((l) => {
|
|
296
|
+
const x = Math.max(0, Math.floor(l.rect.x - clip.x));
|
|
297
|
+
const y = Math.max(0, Math.floor(l.rect.y - clip.y));
|
|
298
|
+
const rect = { x, y, width: Math.min(img.width - x, Math.ceil(l.rect.width)), height: Math.min(img.height - y, Math.ceil(l.rect.height)) };
|
|
299
|
+
const rgb = parseColor(l.color);
|
|
300
|
+
if (!rgb) return { el: l.el, line: l.line, min: null, why: `colour ${l.color}` };
|
|
301
|
+
if (rect.width < 1 || rect.height < 1) return { el: l.el, line: l.line, min: null, why: 'line outside the panel' };
|
|
302
|
+
return { el: l.el, line: l.line, min: Number(worstContrast(rgb, img.data, img.width, rect, colorAlpha(l.color)).toFixed(2)) };
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function measureScene(page, i, o) {
|
|
307
|
+
await goToScene(page, i);
|
|
308
|
+
const clip = await clipState(page, i, o.reduce);
|
|
309
|
+
const g = geometry(await page.evaluate(rawGeometry, { i, header: o.header, covers: COVERS }));
|
|
310
|
+
const contrast = o.contrast && g.opacity > 0.5 ? await lineContrast(page, i, g.panel) : [];
|
|
311
|
+
const scene = { scene: i + 1, clip, ...g, contrast };
|
|
312
|
+
return { ...scene, failures: g.opacity > 0.5 ? judge(scene, o) : ['panel not visible at its stop'] };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- input checks (step navigation only) ---------------------------------------------
|
|
316
|
+
|
|
317
|
+
async function swipe(page, vp, dir) {
|
|
318
|
+
const cdp = await page.context().newCDPSession(page);
|
|
319
|
+
const x = Math.round(vp.width / 2);
|
|
320
|
+
const [from, to] = dir === 'down' ? [vp.height * 0.7, vp.height * 0.3] : [vp.height * 0.3, vp.height * 0.7];
|
|
321
|
+
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: Math.round(from) }] });
|
|
322
|
+
for (let k = 1; k <= 6; k++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: Math.round(from + ((to - from) * k) / 6) }] });
|
|
323
|
+
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
324
|
+
await cdp.detach();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const KEY = { ArrowDown: ['ArrowDown', 'ArrowUp'], PageDown: ['PageDown', 'PageUp'], Space: ['Space', 'Shift+Space'] };
|
|
328
|
+
|
|
329
|
+
async function send(page, vp, how, dir) {
|
|
330
|
+
if (how === 'swipe') return swipe(page, vp, dir);
|
|
331
|
+
if (how === 'wheel') {
|
|
332
|
+
await page.mouse.move(vp.width / 2, vp.height / 2);
|
|
333
|
+
return page.mouse.wheel(0, dir === 'down' ? 120 : -120);
|
|
334
|
+
}
|
|
335
|
+
return page.keyboard.press(KEY[how][dir === 'down' ? 0 : 1]);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// One notch, then a decaying inertia tail within the gesture gap.
|
|
339
|
+
async function notchWithInertia(page, vp) {
|
|
340
|
+
await page.mouse.move(vp.width / 2, vp.height / 2);
|
|
341
|
+
for (const dy of [120, 90, 60, 40, 24, 12, 6, 3]) {
|
|
342
|
+
await page.mouse.wheel(0, dy);
|
|
343
|
+
await page.waitForTimeout(16);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function bodyRoundTrip(page, vp, how, last) {
|
|
348
|
+
await goToScene(page, last);
|
|
349
|
+
await page.waitForTimeout(400);
|
|
350
|
+
await send(page, vp, how, 'down');
|
|
351
|
+
await settle(page);
|
|
352
|
+
const reachesBody = (await navInfo(page)).state.atBody;
|
|
353
|
+
await send(page, vp, how, 'up');
|
|
354
|
+
await settle(page);
|
|
355
|
+
return { reachesBody, upReturns: (await navInfo(page)).state.index === last };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const ready = (page) => page.evaluate(() => {
|
|
359
|
+
const b = document.querySelector('.isn-main');
|
|
360
|
+
return document.documentElement.classList.contains('isn-ready') && Boolean(b) && getComputedStyle(b).display !== 'none';
|
|
361
|
+
});
|
|
362
|
+
const bodyFocused = (page, end) => page.evaluate((sel) => document.activeElement === document.querySelector(sel), end);
|
|
363
|
+
|
|
364
|
+
// The main button: hidden before the last scene, shown once its clip has played to the end (at
|
|
365
|
+
// once under reduced motion), and a press goes to the body with the focus. The skip link likewise.
|
|
366
|
+
async function checkButtons(page, info) {
|
|
367
|
+
await goToScene(page, info.last - 1);
|
|
368
|
+
const hiddenBefore = !(await ready(page));
|
|
369
|
+
await goToScene(page, info.last);
|
|
370
|
+
const shownAtLast = await ready(page);
|
|
371
|
+
await page.click('.isn-main');
|
|
372
|
+
await settle(page);
|
|
373
|
+
const main = { hiddenBefore, shownAtLast, reachesBody: (await navInfo(page)).state.atBody, focusesBody: await bodyFocused(page, info.end) };
|
|
374
|
+
await goToScene(page, 0);
|
|
375
|
+
await page.click('.isn-skip');
|
|
376
|
+
await settle(page);
|
|
377
|
+
return { main, skip: { reachesBody: (await navInfo(page)).state.atBody, focusesBody: await bodyFocused(page, info.end) } };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function checkInput(page, vp) {
|
|
381
|
+
const info = await navInfo(page);
|
|
382
|
+
if (!info || info.last < 1) return null;
|
|
383
|
+
const out = {};
|
|
384
|
+
if (!vp.touch) {
|
|
385
|
+
await goToScene(page, info.last - 1);
|
|
386
|
+
await notchWithInertia(page, vp);
|
|
387
|
+
await settle(page);
|
|
388
|
+
out.inertiaStopsAtLast = (await navInfo(page)).state.index === info.last;
|
|
389
|
+
}
|
|
390
|
+
if (info.stops.length - 1 > info.last) {
|
|
391
|
+
for (const how of vp.touch ? ['swipe'] : ['wheel', 'ArrowDown', 'PageDown', 'Space']) out[how] = await bodyRoundTrip(page, vp, how, info.last);
|
|
392
|
+
Object.assign(out, await checkButtons(page, info));
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Every false check of the input results, as `input <check>: <what>`.
|
|
398
|
+
const inputFailures = (input) => (input ? Object.entries(input).flatMap(([k, v]) => (typeof v === 'boolean'
|
|
399
|
+
? (v ? [] : [`input ${k}`]) : Object.entries(v).filter(([, ok]) => !ok).map(([what]) => `input ${k}: ${what}`))) : []);
|
|
400
|
+
|
|
401
|
+
async function measureViewport(browser, url, vp, o) {
|
|
402
|
+
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: 1,
|
|
403
|
+
hasTouch: Boolean(vp.touch), isMobile: Boolean(vp.touch), reducedMotion: vp.reduce ? 'reduce' : 'no-preference' });
|
|
404
|
+
const page = await context.newPage();
|
|
405
|
+
const consoleErrors = [];
|
|
406
|
+
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
|
|
407
|
+
page.on('pageerror', (e) => consoleErrors.push(String(e.message || e)));
|
|
408
|
+
await page.goto(url, { waitUntil: 'load' });
|
|
409
|
+
await page.waitForSelector('.sw-copy');
|
|
410
|
+
const scenes = [];
|
|
411
|
+
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) }));
|
|
412
|
+
const input = o.input ? await checkInput(page, vp) : null;
|
|
413
|
+
const buttons = o.contrast ? await topCtaContrast(page) : [];
|
|
414
|
+
await context.close();
|
|
415
|
+
const buttonMisses = buttonFailures(buttons, o.minContrast);
|
|
416
|
+
const failures = scenes.reduce((a, s) => a + s.failures.length, 0) + consoleErrors.length + inputFailures(input).length + buttonMisses.length;
|
|
417
|
+
return { viewport: `${vp.width}x${vp.height}${vp.touch ? ' touch' : ''}${vp.reduce ? ' reduced-motion' : ''}`, scenes, input, inputFailures: inputFailures(input),
|
|
418
|
+
buttons, buttonFailures: buttonMisses, consoleErrors, failures };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Measures `url` at every viewport. Resolves to { url, viewports: [...], failures }. */
|
|
422
|
+
export async function verifyIntro(url, { viewports = VIEWPORTS, header = '.sw-topbar', minContrast = 4.5, input = true, contrast = true } = {}) {
|
|
423
|
+
const { chromium } = await loadPlaywright();
|
|
424
|
+
const browser = await chromium.launch();
|
|
425
|
+
try {
|
|
426
|
+
const results = [];
|
|
427
|
+
for (const vp of viewports) results.push(await measureViewport(browser, url, vp, { header, minContrast, input, contrast }));
|
|
428
|
+
return { url, viewports: results, failures: results.reduce((a, r) => a + r.failures, 0) };
|
|
429
|
+
} finally {
|
|
430
|
+
await browser.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// '390x844t' is a touch screen, '1280x800r' reduced motion; the flags may combine ('390x844tr').
|
|
435
|
+
export function parseViewport(v) {
|
|
436
|
+
const m = /^(\d+)x(\d+)([tr]*)$/.exec(v.trim());
|
|
437
|
+
if (!m) throw new Error(`verify-intro: not a screen size: ${v}`);
|
|
438
|
+
return { width: Number(m[1]), height: Number(m[2]), touch: m[3].includes('t'), reduce: m[3].includes('r') };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const FLAGS = { '--out': 'out', '--header': 'header', '--min-contrast': 'minContrast', '--viewports': 'viewports' };
|
|
442
|
+
const SWITCHES = { '--no-input': ['input', false], '--no-contrast': ['contrast', false] };
|
|
443
|
+
|
|
444
|
+
/** The command line: a URL, --flag value or --flag=value, and the two switches. */
|
|
445
|
+
export function parseArgs(argv) {
|
|
446
|
+
const o = { url: null, out: null, header: '.sw-topbar', minContrast: 4.5, viewports: VIEWPORTS, input: true, contrast: true };
|
|
447
|
+
for (let i = 0; i < argv.length; i++) {
|
|
448
|
+
const [flag, inline] = argv[i].includes('=') ? [argv[i].slice(0, argv[i].indexOf('=')), argv[i].slice(argv[i].indexOf('=') + 1)] : [argv[i], null];
|
|
449
|
+
if (SWITCHES[flag]) o[SWITCHES[flag][0]] = SWITCHES[flag][1];
|
|
450
|
+
else if (FLAGS[flag]) o[FLAGS[flag]] = inline !== null ? inline : argv[++i];
|
|
451
|
+
else if (flag.startsWith('--')) throw new Error(`verify-intro: unknown option ${flag}`);
|
|
452
|
+
else o.url = argv[i];
|
|
453
|
+
}
|
|
454
|
+
o.minContrast = Number(o.minContrast);
|
|
455
|
+
if (typeof o.viewports === 'string') o.viewports = o.viewports.split(',').map(parseViewport);
|
|
456
|
+
return o;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
460
|
+
if (invokedDirectly) {
|
|
461
|
+
let o;
|
|
462
|
+
try { o = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); process.exit(2); }
|
|
463
|
+
if (!o.url) {
|
|
464
|
+
console.error('usage: node verify-intro.mjs <url> [--out report.json] [--header <selector>] [--min-contrast 4.5] [--viewports 1280x800,390x844t] [--no-input] [--no-contrast]');
|
|
465
|
+
process.exit(2);
|
|
466
|
+
}
|
|
467
|
+
verifyIntro(o.url, o).then((r) => {
|
|
468
|
+
const text = JSON.stringify(r, null, 2);
|
|
469
|
+
if (o.out) fs.writeFileSync(o.out, text + '\n');
|
|
470
|
+
else console.log(text);
|
|
471
|
+
console.error(`verify-intro: ${r.failures} failure(s) across ${r.viewports.length} screen size(s)`);
|
|
472
|
+
process.exit(r.failures ? 1 : 0);
|
|
473
|
+
}).catch((e) => { console.error(String(e?.message || e)); process.exit(1); });
|
|
474
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# 3D 인트로 독립 검증 목록
|
|
2
|
+
|
|
3
|
+
작성자와 다른 검증 주체가 이 목록으로 판정한다. 측정할 수 있는 항목은 `verify-intro.mjs` 가 잰다. 눈으로 볼 항목은 검증자가 직접 본다.
|
|
4
|
+
|
|
5
|
+
## 1. 측정 (verify-intro.mjs)
|
|
6
|
+
|
|
7
|
+
Node 용 playwright 패키지와 Chromium 이 필요하다(`npm i -g playwright && npx playwright install chromium`, 또는 프로젝트에 `npm i -D playwright`).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
node references/verify-intro.mjs http://localhost:<port>/ --out verify.json
|
|
11
|
+
node references/verify-intro.mjs http://localhost:<port>/ --viewports 1280x800r --out verify-reduced.json # 움직임 줄이기
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| 항목 | 기준 |
|
|
15
|
+
|---|---|
|
|
16
|
+
| 화면 크기 | 1280x800, 1920x1080, 860x900, 390x844 터치, 375x667 터치, 844x390 터치(가로 휴대폰). 지원 언어마다 그 언어 주소로 반복. 다른 크기를 지원하면 `--viewports` 에 더함 |
|
|
17
|
+
| 컨텍스트 | 화면 크기마다 새 브라우저 컨텍스트(재빌드 뒤 캐시 회피) |
|
|
18
|
+
| 패널 위치 | 화면 안, 고정 헤더 아래(`--header` 로 사이트 헤더 지정). 1px 여유 |
|
|
19
|
+
| 겹침 | 패널이 안내, 건너뛰기 링크, 메인화면 버튼, 장면 점과 겹치지 않음 |
|
|
20
|
+
| 가로 넘침 | 문서 폭이 화면 폭을 넘지 않음 |
|
|
21
|
+
| 콘솔 오류 | 0. CSP 거부가 있으면 정책과 `styleHash` 를 확인 |
|
|
22
|
+
| 대비 | 글자를 숨긴 채 패널을 찍고, 글자 줄마다(CTA 버튼 글자 포함) 가장 나쁜 배경 픽셀 기준 4.5 이상. 상단 CTA 는 제 바탕색 기준 4.5 이상. 바탕이 단색이 아니면(반투명, 그라데이션) 잴 수 없어 not measured 실패로 남으니, 눈으로 보고 판정에 적음 |
|
|
23
|
+
| 클립 | 두 번째 장면부터 클립이 있으면 5초 안에 실제로 그려짐. 첫 장면(첫 이동 전까지 스틸)과 움직임 줄이기(클립 없음)는 판정하지 않음 |
|
|
24
|
+
| 입력 (장면 단위 이동) | 직전 장면에서 휠 1칸과 관성 1회가 마지막 장면에 멈춤 |
|
|
25
|
+
| 입력 (본문이 있을 때) | 마지막 장면에서 휠, 아래 방향키, PageDown, Space, 스와이프 1회로 본문 맨 위에 섬. 위 입력 1회로 마지막 장면 복귀 |
|
|
26
|
+
| 버튼과 링크 | 메인화면 버튼은 마지막 장면 전에는 숨고 도착하면 보임. 누르면 본문으로 가서 포커스가 본문에 감. 건너뛰기 링크도 같음 |
|
|
27
|
+
| 움직임 줄이기 | 이동 시간 0. 메인화면 버튼이 마지막 장면에서 보임 |
|
|
28
|
+
|
|
29
|
+
## 2. 눈으로 보는 항목
|
|
30
|
+
|
|
31
|
+
| 항목 | 기준 |
|
|
32
|
+
|---|---|
|
|
33
|
+
| 장면의 뜻 | 장면 하나에 뜻 하나. 이야기가 인터뷰에서 정한 순서로 읽힘(예: 문제 제기, 해법, 결론) |
|
|
34
|
+
| 행위 주체 | 누가 무엇을 하는지 화면에서 드러남(예: 문제를 일으키는 쪽과 푸는 쪽, 제품을 쓰는 사람) |
|
|
35
|
+
| 표현 | 브랜드 톤에 맞음. 의도하지 않은 만화식 은유가 없음 |
|
|
36
|
+
| 화면 속 글자 | 깨진 글자, 뜻 없는 글자가 없음. 있으면 그 스틸이나 클립만 재생성 |
|
|
37
|
+
| 사물 | 사물이 엉뚱한 위치에 뜨거나 이상하게 움직이지 않음. 색이 다른 물체로 번지지 않음 |
|
|
38
|
+
| 이음매 | chain 은 경미한 톤 drift 까지 허용. holdFlight 는 비행 클립의 첫과 끝 프레임이 장면과 맞음 |
|
|
39
|
+
| 첫 이동 | 첫 장면에서 처음 움직일 때 영상이 바로 나옴(클립 미리 받기) |
|
|
40
|
+
| 문구 | 375px 화면에서 본문이 넘치지 않음. 유리 패널이면 본문은 한 줄에 항목 하나(권장 세 줄). CTA 는 마지막 장면에만 |
|
|
41
|
+
|
|
42
|
+
## 3. 판정
|
|
43
|
+
|
|
44
|
+
- 측정 실패가 0 이고 눈으로 보는 항목에 지적이 없으면 APPROVE.
|
|
45
|
+
- 지적이 있으면 REQUEST CHANGES 와 함께 화면 크기, 장면 번호, 항목, 측정값을 적는다. 작성자는 그 항목만 고치고 같은 검증자가 다시 잰다.
|