astack-skills 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/astack/references/completion-checks.md +2 -1
- package/skills/astack/scripts/shoot.mjs +148 -16
- package/skills/deslop/SKILL.md +5 -1
- package/skills/deslop/scripts/slop-scan.mjs +4 -0
- package/skills/dogfood/SKILL.md +3 -0
- package/skills/dogfood/scripts/shoot.mjs +148 -16
- package/skills/launch/SKILL.md +1 -1
- package/skills/launch/references/film.md +1 -1
- package/skills/launch/references/kit.md +9 -2
- package/skills/launch/scripts/shoot.mjs +148 -16
- package/skills/oneshot/scripts/shoot.mjs +148 -16
- package/skills/ship/scripts/shoot.mjs +148 -16
- package/skills/web-design/SKILL.md +3 -2
- package/skills/web-design/references/anti-patterns.md +16 -0
- package/skills/web-design/references/craft-floor.md +1 -1
- package/skills/web-design/references/loading.md +35 -0
- package/skills/web-design/references/mobile.md +16 -2
- package/skills/web-design/references/worst-case-data.md +1 -1
- package/skills/web-design/scripts/shoot.mjs +148 -16
- package/skills/web-graphics/SKILL.md +3 -3
- package/skills/web-graphics/scripts/shoot.mjs +148 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
Measured checks for defects a settled screenshot hides, and a harder rule for launch stills. No library was added.
|
|
6
|
+
|
|
7
|
+
- `shoot.mjs` reads the rendered page and reports inputs under 16px, a viewport that disables zoom, `:hover` rules outside `@media (hover: hover)`, selectable controls on a narrow viewport, and a missing tap-highlight reset. `--shift` compares a loading page with the settled page. It exits 1 when a shared region or the document scroll height moved by more than 1px, and exits 2 when no region was marked and the scroll height did not change. It does not read the performance timeline's layout-shift entries: headless Chrome can report none for a jump that has already painted, and a score of 0 would pass that page.
|
|
8
|
+
- `web-design` states how to build that loading layout, and `mobile.md` says which of those lines a desktop capture can see. `dogfood` and `astack`'s completion checks read them before calling a phone journey done.
|
|
9
|
+
- `deslop` names this season's UI tells with examples (pill tags, italic headline words, dividers, opacity ladders, borders on every box, over-detailed diagrams, decorative breadcrumbs, section numbers). `slop-scan.mjs` flags `section-index` and `opacity-ramp`.
|
|
10
|
+
- `launch` and `web-graphics`: a bento, device or clip may frame a real capture. It may not redraw the screen, and it may not show a number the agreed copy file does not contain.
|
|
11
|
+
- New `shift-and-still` eval fixture, with the planted failures named. The blind comparison was not run. The deterministic `shoot.mjs --shift` run is recorded in `evals/RESULTS.md`.
|
|
12
|
+
|
|
3
13
|
## 0.7.1
|
|
4
14
|
|
|
5
15
|
- Keep coupled work in one agent context and load skills as needed. Independent review now depends on risk, unresolved judgment and repository requirements.
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ Each one has a short guide in [docs/skills](docs/skills/).
|
|
|
72
72
|
|
|
73
73
|
Most skill packs tell the model to be thorough. These come with small scripts so it can actually check. Node 22, nothing else to install, and every skill carries its own copy.
|
|
74
74
|
|
|
75
|
-
- `shoot.mjs` screenshots a page on a phone and on desktop, light and dark, and flags stranded words, tiny tap targets
|
|
75
|
+
- `shoot.mjs` screenshots a page on a phone and on desktop, light and dark, and flags stranded words, tiny tap targets, anything spilling sideways, inputs that will zoom the page, hover that is not gated to a real pointer, and (with `--shift`) a loading state whose boxes do not match the loaded page.
|
|
76
76
|
- `pr-state.mjs` reads every check on the current commit and every unresolved review thread. `--watch` sits in the background and pings you when something changes.
|
|
77
77
|
- `verify-live.mjs` confirms the URL is serving the commit you shipped, not a cached copy of the last one.
|
|
78
78
|
- `slop-scan.mjs` finds the usual suspects, from "seamless" to gradient headlines to emoji bullets, and explains why each one reads as generated. <!-- slop-scan: ignore-line -->
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "astack",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "Skills for Claude Code and Codex that take product work from the thread to a proven, live release.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Andrew Wilkinson",
|
|
@@ -13,7 +13,8 @@ Run this before writing "done". Every line is either checked, not applicable (sa
|
|
|
13
13
|
|
|
14
14
|
- [ ] Captured at 390 and 1440, light and dark, reduced motion on (`node <skill-dir>/scripts/shoot.mjs <url>`), and the screenshots were looked at.
|
|
15
15
|
- [ ] No horizontal overflow, stranded single words in headings, tap targets under 44px or text under 12px that you did not choose on purpose.
|
|
16
|
-
- [ ]
|
|
16
|
+
- [ ] Phone lines from that capture are clean or chosen on purpose: no input under 16px, no zoom lock, no ungated `:hover`, no selectable control text, tap highlight disabled. Name anything that still needs a phone in the hand.
|
|
17
|
+
- [ ] Loading, empty, error and success states seen, not assumed. Where a screen waits on data, `shoot.mjs <loading> --shift <loaded>` exits 0. Exit 1 is a shift to fix. Exit 2 means the regions were not marked, which is not a pass.
|
|
17
18
|
- [ ] `deslop` scan run on changed copy and UI; every medium/high hit fixed or dismissed with a reason.
|
|
18
19
|
|
|
19
20
|
## Measurement (when the change has a success action)
|
|
@@ -29,23 +29,43 @@ Options:
|
|
|
29
29
|
offsets depend on capture speed and are reported)
|
|
30
30
|
--click SELECTOR Click this element before capturing (e.g. to open a menu)
|
|
31
31
|
--scale N Device scale factor (default: 2)
|
|
32
|
+
--shift URL Load this second page at the same sizes. The first
|
|
33
|
+
page is the loading state, this one the settled state.
|
|
34
|
+
Reports regions (same id or data-region) whose box
|
|
35
|
+
moved by more than 1px, and any change in document
|
|
36
|
+
scroll height. Exits 1 when one did. Exits 2 when no
|
|
37
|
+
region was marked and the scroll height did not change,
|
|
38
|
+
which is not a pass. Settled shots are named with -settled.
|
|
32
39
|
--json Print a JSON manifest instead of text
|
|
33
40
|
|
|
34
41
|
Set SHOOT_CHROME_FLAGS for extra browser flags. --no-sandbox is added
|
|
35
42
|
automatically when CI is set or when running as root.
|
|
36
43
|
|
|
37
|
-
Also reports: horizontal overflow,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
Also reports, per capture: horizontal overflow, elements wider than the
|
|
45
|
+
viewport, stranded words in wrapped text, tap targets under 44px and
|
|
46
|
+
selectable control text (widths < 768), text under 12px, inputs under 16px,
|
|
47
|
+
a viewport that disables zoom, :hover rules outside @media (hover: hover),
|
|
48
|
+
a missing tap-highlight reset when the page has controls, console errors
|
|
49
|
+
and failed requests. Set CHROME_PATH to override browser discovery. The
|
|
50
|
+
temporary browser profile is deleted afterwards. Exits 1 if a page fails
|
|
51
|
+
to load, returns an HTTP error, or --shift finds a moved region or a
|
|
52
|
+
document scroll height change of more than 1px. Exits 2 when --shift could
|
|
53
|
+
not compare a marked region and the scroll height did not change. A bare
|
|
43
54
|
host:port (localhost:3000) is treated as http://.
|
|
55
|
+
The layout-shift entries on the performance timeline are not read. Headless
|
|
56
|
+
Chrome can report none of them for a jump that has already painted, so a
|
|
57
|
+
score of 0 would pass a page that moved. The two-page box compare is the check.
|
|
44
58
|
`;
|
|
45
59
|
|
|
60
|
+
function resolveTarget(value) {
|
|
61
|
+
if (/^[a-z]+:\/\//i.test(value)) return value;
|
|
62
|
+
return !existsSync(value) && /^(?:localhost|[\w-]+(?:\.[\w-]+)+|\d+\.\d+\.\d+\.\d+)(?::\d+)?(?:\/|$)|^[\w.-]+:\d+/.test(value)
|
|
63
|
+
? `http://${value}` : pathToFileURL(resolve(value)).href;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
function parse(argv) {
|
|
47
|
-
const o = { target: "", out: "shots", widths: [390, 1440], height: 0, themes: ["light", "dark"],
|
|
48
|
-
motion: "reduce", full: false, wait: 600, frames: 0, every: 100, click: "", scale: 2, json: false };
|
|
67
|
+
const o = { target: "", shift: "", out: "shots", widths: [390, 1440], height: 0, themes: ["light", "dark"],
|
|
68
|
+
motion: "reduce", full: false, wait: 600, frames: 0, every: 100, click: "", scale: 2, shotSuffix: "", json: false };
|
|
49
69
|
for (let i = 0; i < argv.length; i++) {
|
|
50
70
|
const a = argv[i], v = () => { const x = argv[++i]; if (x === undefined) throw new Error(`${a} needs a value`); return x; };
|
|
51
71
|
if (a === "--help" || a === "-h") { process.stdout.write(HELP); process.exit(0); }
|
|
@@ -60,14 +80,14 @@ function parse(argv) {
|
|
|
60
80
|
else if (a === "--every") o.every = Number(v());
|
|
61
81
|
else if (a === "--click") o.click = v();
|
|
62
82
|
else if (a === "--scale") o.scale = Number(v());
|
|
83
|
+
else if (a === "--shift") o.shift = v();
|
|
63
84
|
else if (a === "--json") o.json = true;
|
|
64
85
|
else if (a.startsWith("--")) throw new Error(`Unknown option ${a}`);
|
|
65
86
|
else o.target = a;
|
|
66
87
|
}
|
|
67
88
|
if (!o.target) { process.stdout.write(HELP); process.exit(1); }
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
? `http://${o.target}` : pathToFileURL(resolve(o.target)).href;
|
|
89
|
+
o.target = resolveTarget(o.target);
|
|
90
|
+
if (o.shift) o.shift = resolveTarget(o.shift);
|
|
71
91
|
if (o.frames) o.motion = "no-preference";
|
|
72
92
|
return o;
|
|
73
93
|
}
|
|
@@ -197,7 +217,7 @@ async function capture(cdp, o, width, theme) {
|
|
|
197
217
|
if (!o.frames) await sleep(o.wait);
|
|
198
218
|
}
|
|
199
219
|
const files = [], frameTimes = [];
|
|
200
|
-
const base = `${String(width)}-${theme}`;
|
|
220
|
+
const base = `${String(width)}-${theme}${o.shotSuffix || ""}`;
|
|
201
221
|
if (o.frames) {
|
|
202
222
|
// Capture first, measure the page afterwards, so frame 0 is as close to the trigger as possible.
|
|
203
223
|
for (let f = 0; f < o.frames; f++) {
|
|
@@ -277,8 +297,64 @@ async function capture(cdp, o, width, theme) {
|
|
|
277
297
|
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
278
298
|
if (size < 12) { tinyText.push(label(el) + " " + size + "px"); if (tinyText.length >= 5) break; }
|
|
279
299
|
}
|
|
300
|
+
const inputsUnder16 = [];
|
|
301
|
+
for (const el of document.querySelectorAll("input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=button]):not([type=submit]):not([type=reset]):not([type=file]):not([type=image]), textarea, select")) {
|
|
302
|
+
if (!visible(el)) continue;
|
|
303
|
+
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
304
|
+
if (size < 16) { inputsUnder16.push((el.getAttribute("aria-label") || el.id || el.getAttribute("name") || el.getAttribute("placeholder") || el.tagName.toLowerCase()) + " " + (Math.round(size * 10) / 10) + "px"); if (inputsUnder16.length >= 8) break; }
|
|
305
|
+
}
|
|
306
|
+
const viewportMeta = document.querySelector('meta[name="viewport" i]')?.getAttribute("content") || "";
|
|
307
|
+
const zoomLocked = /user-scalable\\s*=\\s*(?:no|0)\\b|maximum-scale\\s*=\\s*1(?:\\.0+)?(?![.\\d])/i.test(viewportMeta);
|
|
308
|
+
const ungatedHovers = [];
|
|
309
|
+
let ungatedHoverCount = 0, tapHighlightDisabled = false;
|
|
310
|
+
const coversRoot = selector => selector.split(",").some(s => /^(?:html|:root|\\*|body)(?:\\s|$)/.test(s.trim()));
|
|
311
|
+
const visitRules = (rules, media) => {
|
|
312
|
+
if (!rules) return;
|
|
313
|
+
for (const rule of rules) {
|
|
314
|
+
if (rule.type === 4) visitRules(rule.cssRules, (media ? media + " " : "") + (rule.conditionText || ""));
|
|
315
|
+
else if (rule.type === 3 && rule.styleSheet) { try { visitRules(rule.styleSheet.cssRules, media); } catch (e) { /* unreadable import */ } }
|
|
316
|
+
else {
|
|
317
|
+
if (rule.type === 1 && rule.selectorText) {
|
|
318
|
+
if (/:hover\\b/.test(rule.selectorText) && !/\\(\\s*(?:any-)?hover\\s*:\\s*hover\\s*\\)/.test(media) && !/hover\\s*:\\s*none/.test(media)) {
|
|
319
|
+
ungatedHoverCount++;
|
|
320
|
+
if (ungatedHovers.length < 8) ungatedHovers.push(rule.selectorText.slice(0, 100));
|
|
321
|
+
}
|
|
322
|
+
const tap = rule.style && rule.style.getPropertyValue("-webkit-tap-highlight-color");
|
|
323
|
+
if (tap && /transparent|rgba\\(\\s*0\\s*,\\s*0\\s*,\\s*0\\s*,\\s*0\\s*\\)/i.test(tap) && coversRoot(rule.selectorText)) tapHighlightDisabled = true;
|
|
324
|
+
}
|
|
325
|
+
if (rule.cssRules && rule.type !== 4) visitRules(rule.cssRules, media);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const seenSheets = new Set();
|
|
330
|
+
for (const sheet of [...document.styleSheets, ...(document.adoptedStyleSheets || [])]) {
|
|
331
|
+
if (seenSheets.has(sheet)) continue;
|
|
332
|
+
seenSheets.add(sheet);
|
|
333
|
+
try { visitRules(sheet.cssRules, ""); } catch (e) { /* cross-origin sheet */ }
|
|
334
|
+
}
|
|
335
|
+
const inlineTap = document.documentElement.style.getPropertyValue("-webkit-tap-highlight-color");
|
|
336
|
+
if (inlineTap && /transparent/i.test(inlineTap)) tapHighlightDisabled = true;
|
|
337
|
+
const hasControls = !!document.querySelector("a[href], button, [role=button], input, select, textarea");
|
|
338
|
+
const selectableControls = [];
|
|
339
|
+
if (vw < 768) for (const el of document.querySelectorAll("button, [role=button]")) {
|
|
340
|
+
if (!visible(el) || !(el.innerText || "").trim()) continue;
|
|
341
|
+
const cs = getComputedStyle(el);
|
|
342
|
+
if ((cs.userSelect || cs.webkitUserSelect) !== "none") { selectableControls.push(label(el)); if (selectableControls.length >= 8) break; }
|
|
343
|
+
}
|
|
344
|
+
const regions = [], seenKeys = new Set();
|
|
345
|
+
for (const el of document.querySelectorAll("[id], [data-region]")) {
|
|
346
|
+
if (!visible(el)) continue;
|
|
347
|
+
const key = el.id ? "#" + el.id : "region:" + el.getAttribute("data-region");
|
|
348
|
+
if (seenKeys.has(key)) continue;
|
|
349
|
+
seenKeys.add(key);
|
|
350
|
+
const box = el.getBoundingClientRect();
|
|
351
|
+
regions.push({ key, top: Math.round(box.top), left: Math.round(box.left), width: Math.round(box.width), height: Math.round(box.height) });
|
|
352
|
+
if (regions.length >= 80) break;
|
|
353
|
+
}
|
|
280
354
|
return { scrollWidth: document.documentElement.scrollWidth, viewport: vw, overflowing: wide.map(w => w.text), stranded, smallTargets, tinyText,
|
|
281
|
-
height: document.documentElement.scrollHeight, title: document.title
|
|
355
|
+
height: document.documentElement.scrollHeight, title: document.title,
|
|
356
|
+
inputsUnder16, zoomLocked, ungatedHovers, ungatedHoverCount, tapHighlightDisabled: hasControls ? tapHighlightDisabled : null,
|
|
357
|
+
selectableControls, regions };
|
|
282
358
|
})()` });
|
|
283
359
|
if (!o.frames) {
|
|
284
360
|
const shot = await cdp.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: o.full });
|
|
@@ -287,10 +363,36 @@ async function capture(cdp, o, width, theme) {
|
|
|
287
363
|
}
|
|
288
364
|
const l = layout.result.value;
|
|
289
365
|
return { width, height, theme, motion: o.motion, files, ...(o.frames ? { frameTimesMs: frameTimes } : {}), title: l.title,
|
|
366
|
+
scrollHeight: l.height, regions: l.regions,
|
|
367
|
+
inputsUnder16: l.inputsUnder16, zoomLocked: l.zoomLocked, ungatedHovers: l.ungatedHovers, ungatedHoverCount: l.ungatedHoverCount,
|
|
368
|
+
tapHighlightDisabled: l.tapHighlightDisabled, selectableControls: l.selectableControls,
|
|
290
369
|
horizontalOverflow: l.scrollWidth > l.viewport ? `${l.scrollWidth - l.viewport}px` : null,
|
|
291
370
|
overflowingElements: l.overflowing, strandedWords: l.stranded, smallTapTargets: l.smallTargets, tinyText: l.tinyText, ...issues };
|
|
292
371
|
}
|
|
293
372
|
|
|
373
|
+
// Same id or data-region in both pages. A disappearing spinner is not a shift;
|
|
374
|
+
// a card that grows, or a control that moves, is.
|
|
375
|
+
function compareShift(before, after) {
|
|
376
|
+
const settled = new Map((after.regions || []).map(r => [r.key, r]));
|
|
377
|
+
const shifts = [];
|
|
378
|
+
let shared = 0;
|
|
379
|
+
for (const region of before.regions || []) {
|
|
380
|
+
const other = settled.get(region.key);
|
|
381
|
+
if (!other) continue;
|
|
382
|
+
shared++;
|
|
383
|
+
const dx = other.left - region.left, dy = other.top - region.top, dw = other.width - region.width, dh = other.height - region.height;
|
|
384
|
+
if (Math.abs(dx) > 1 || Math.abs(dy) > 1 || Math.abs(dw) > 1 || Math.abs(dh) > 1)
|
|
385
|
+
shifts.push({ key: region.key, before: region, after: other, dx, dy, dw, dh });
|
|
386
|
+
}
|
|
387
|
+
const scrollDelta = (after.scrollHeight ?? 0) - (before.scrollHeight ?? 0);
|
|
388
|
+
return {
|
|
389
|
+
scrollBefore: before.scrollHeight ?? null, scrollAfter: after.scrollHeight ?? null,
|
|
390
|
+
scrollDelta: Math.abs(scrollDelta) > 1 ? scrollDelta : 0, shared, shifts,
|
|
391
|
+
settledFiles: after.files,
|
|
392
|
+
...(shared === 0 ? { note: "no shared id or data-region; only the document scroll height was compared" } : {}),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
294
396
|
async function main() {
|
|
295
397
|
const o = parse(process.argv.slice(2));
|
|
296
398
|
mkdirSync(o.out, { recursive: true });
|
|
@@ -300,15 +402,31 @@ async function main() {
|
|
|
300
402
|
for (const width of o.widths) for (const theme of o.themes) {
|
|
301
403
|
const cdp = await connect(browser.port);
|
|
302
404
|
await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); await cdp.send("Network.enable");
|
|
303
|
-
|
|
405
|
+
const before = await capture(cdp, o, width, theme);
|
|
304
406
|
cdp.close();
|
|
407
|
+
if (o.shift) {
|
|
408
|
+
const settled = await connect(browser.port);
|
|
409
|
+
await settled.send("Page.enable"); await settled.send("Runtime.enable"); await settled.send("Network.enable");
|
|
410
|
+
const after = await capture(settled, { ...o, target: o.shift, shotSuffix: "-settled" }, width, theme);
|
|
411
|
+
settled.close();
|
|
412
|
+
before.shift = compareShift(before, after);
|
|
413
|
+
if (after.loadError && !before.loadError) before.loadError = `settled page: ${after.loadError}`;
|
|
414
|
+
}
|
|
415
|
+
results.push(before);
|
|
305
416
|
}
|
|
306
417
|
} finally { await browser.close(); }
|
|
307
418
|
const failed = results.some(r => r.loadError);
|
|
308
|
-
|
|
419
|
+
const shifted = results.some(r => r.shift && (r.shift.shifts.length > 0 || r.shift.scrollDelta));
|
|
420
|
+
// No marked region and no scroll change means the jump was not measured.
|
|
421
|
+
// Scroll height stays at the viewport when both states fit, so that case must not pass.
|
|
422
|
+
const unchecked = results.some(r => r.shift && r.shift.shared === 0 && !r.shift.scrollDelta);
|
|
423
|
+
if (failed || shifted) process.exitCode = 1;
|
|
424
|
+
else if (unchecked) process.exitCode = 2;
|
|
309
425
|
if (o.json) { process.stdout.write(JSON.stringify({ target: o.target, captures: results }, null, 2) + "\n"); return; }
|
|
310
426
|
for (const r of results) {
|
|
311
|
-
const
|
|
427
|
+
const shiftFlag = r.shift && (r.shift.shifts.length > 0 || r.shift.scrollDelta) ? "layout shift" : "";
|
|
428
|
+
const uncheckedFlag = r.shift && r.shift.shared === 0 && !r.shift.scrollDelta ? "shift not checked" : "";
|
|
429
|
+
const flags = [r.loadError, shiftFlag, uncheckedFlag, r.horizontalOverflow && `horizontal overflow ${r.horizontalOverflow}`,
|
|
312
430
|
r.consoleErrors.length && `${r.consoleErrors.length} console error(s)`,
|
|
313
431
|
r.failedRequests.length && `${r.failedRequests.length} failed request(s)`].filter(Boolean);
|
|
314
432
|
process.stdout.write(`${r.width}px ${r.theme}: ${r.files.join(", ")}${flags.length ? " !! " + flags.join("; ") : ""}\n`);
|
|
@@ -316,6 +434,20 @@ async function main() {
|
|
|
316
434
|
for (const e of r.strandedWords) process.stdout.write(` stranded words: ${e}\n`);
|
|
317
435
|
for (const e of r.smallTapTargets) process.stdout.write(` tap target < 44px: ${e}\n`);
|
|
318
436
|
for (const e of r.tinyText) process.stdout.write(` text < 12px: ${e}\n`);
|
|
437
|
+
for (const e of r.inputsUnder16 || []) process.stdout.write(` input under 16px: ${e}\n`);
|
|
438
|
+
if (r.zoomLocked) process.stdout.write(` viewport disables zoom\n`);
|
|
439
|
+
if (r.ungatedHoverCount) process.stdout.write(` hover not gated (${r.ungatedHoverCount}): ${(r.ungatedHovers || []).join("; ")}\n`);
|
|
440
|
+
if (r.tapHighlightDisabled === false) process.stdout.write(` tap highlight not disabled\n`);
|
|
441
|
+
for (const e of r.selectableControls || []) process.stdout.write(` control text selectable: ${e}\n`);
|
|
442
|
+
if (r.shift) {
|
|
443
|
+
if (r.shift.note) process.stdout.write(` shift: ${r.shift.note}\n`);
|
|
444
|
+
if (r.shift.scrollDelta) process.stdout.write(` document scroll height ${r.shift.scrollDelta > 0 ? "+" : ""}${r.shift.scrollDelta}px after load\n`);
|
|
445
|
+
for (const s of r.shift.shifts.slice(0, 12)) {
|
|
446
|
+
const part = (n, label) => n ? `${label} ${n > 0 ? "+" : ""}${n}px` : "";
|
|
447
|
+
const bits = [part(s.dy, "top"), part(s.dh, "height"), part(s.dx, "left"), part(s.dw, "width")].filter(Boolean);
|
|
448
|
+
process.stdout.write(` layout shift ${s.key}: ${bits.join(", ")}\n`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
319
451
|
for (const e of [...new Set(r.consoleErrors)].slice(0, 5)) process.stdout.write(` console: ${e}\n`);
|
|
320
452
|
for (const e of [...new Set(r.failedRequests)].slice(0, 5)) process.stdout.write(` request: ${e}\n`);
|
|
321
453
|
}
|
package/skills/deslop/SKILL.md
CHANGED
|
@@ -33,7 +33,11 @@ Work through [prose patterns](references/prose-patterns.md). The ones that matte
|
|
|
33
33
|
|
|
34
34
|
## 3. Fix UI
|
|
35
35
|
|
|
36
|
-
Run the scan on components and check the rendered page against `web-design`'s anti-patterns if installed. The usual repairs: delete the eyebrow labels and explainer subtitles; replace gradient text with solid ink; remove glass, glows and sparkles; flatten nested cards; give CTAs real verbs; remove emoji from interface copy.
|
|
36
|
+
Run the scan on components and check the rendered page against `web-design`'s anti-patterns if installed. The usual repairs: delete the eyebrow labels and explainer subtitles; replace gradient text with solid ink; remove glass, glows and sparkles; flatten nested cards; give CTAs real verbs; remove emoji from interface copy.
|
|
37
|
+
|
|
38
|
+
This season's tells, each fine when the brief asks and slop when it shows up anyway: a pill tag above the heading (`New`), one italic word in the headline, a divider under every section, four opacities of the same neutral instead of two inks, a border on every box, a diagram that draws the whole system, a decorative `Home / Settings / Billing`, and `01` `02` `03` on items that are not a sequence. The scan flags the numbered markers (`section-index`) and the opacity ladder (`opacity-ramp`). The rest need the render. Instrument Serif with tight tracking is the current reflex display; it stays only when DESIGN.md names it.
|
|
39
|
+
|
|
40
|
+
Re-shoot and look after removing structure: a deleted container can take a needed grouping with it.
|
|
37
41
|
|
|
38
42
|
## 4. Fix code
|
|
39
43
|
|
|
@@ -65,6 +65,7 @@ const RULES = [
|
|
|
65
65
|
["caps-eyebrow", "ui", "info", /\buppercase\b[^"'`]*\btracking-(?:widest|\[0\.[1-9]\d*em\])|\btracking-(?:widest|\[0\.[1-9]\d*em\])[^"'`]*\buppercase\b|text-transform:\s*uppercase;\s*letter-spacing:\s*0?\.[12]\d*em/g, "Tracked-out all-caps label; the eyebrow above every heading."],
|
|
66
66
|
["generic-cta", "ui", "info", />\s*(?:Get Started|Learn More|Start (?:your )?(?:free )?trial|Join the waitlist|Explore (?:now|features))\s*</g, "Generic CTA label; name what happens next ('Create a listing', 'See rates')."],
|
|
67
67
|
["emoji-in-ui", "ui", "medium", />[^<{]*?(?:🚀|✨|🔥|💡|⚡|🎉|👋|💪|🙌|📈|🎯)[^<]*?</gu, "Emoji in interface copy; usually stands in for an icon system or a point of view."],
|
|
68
|
+
["section-index", "ui", "info", />\s*0[1-9]\s*(?:<\/|—|–|-|\/)/g, "Numbered marker (01, 02) on content that is not a sequence. Delete it, or keep it only where order is the point."],
|
|
68
69
|
// Motion and mobile details that make an interface feel slightly off.
|
|
69
70
|
["transition-all", "ui", "medium", /\btransition-all\b|transition:\s*all\b/g, "Transitions every property, including layout ones nobody meant to animate. Name them: transform, opacity."],
|
|
70
71
|
["ease-in-ui", "ui", "info", /\bease-in\b(?!-out)|(?:transition|animation)(?:-timing-function)?:[^;]*\bease-in\b(?!-out)/g, "ease-in starts slowly, exactly when the user is watching; UI enters and exits on ease-out."],
|
|
@@ -161,6 +162,9 @@ function scanFile(file, o) {
|
|
|
161
162
|
const acid = hexes.some(([r, g, b]) => (g >= 0xd0 && r >= 0x80 && r <= 0xe0 && b <= 0x60) || (r >= 0xe0 && g <= 0x60 && b <= 0x40)) || /\b(?:text|bg|border)-lime-\d{3}\b/.test(src);
|
|
162
163
|
if (cream && serif) density.push({ rule: "warm-editorial", severity: "medium", detail: `cream background with a serif display${clay ? " and a clay/terracotta accent" : ""}`, why: "Today's most common generated look; fine if the brief asked for it, slop if it arrived by default." });
|
|
163
164
|
if (nearBlack && acid) density.push({ rule: "dark-acid", severity: "info", detail: "near-black background with an acid/vermilion accent", why: "A current generated-design default; check it was a choice." });
|
|
165
|
+
const opacities = [...src.matchAll(/\b(?:text|bg)-(?:black|white|neutral|zinc|gray|slate|stone)\/(?:[1-9]\d)\b/g)].map(m => m[0].replace(/.*\//, ""));
|
|
166
|
+
const distinct = [...new Set(opacities)];
|
|
167
|
+
if (distinct.length >= 4) density.push({ rule: "opacity-ramp", severity: "info", detail: `${distinct.length} opacities of one neutral (${distinct.sort().join(", ")})`, why: "A ladder of opacities standing in for a type scale. Two inks, primary and secondary, are enough unless the brand defines more." });
|
|
164
168
|
}
|
|
165
169
|
const boldLeads = hits.filter(h => h.rule === "bold-lead-list").length;
|
|
166
170
|
if (boldLeads > DENSITY["bold-lead-list"]) density.push({ rule: "bold-lead-density", severity: "medium", detail: `${boldLeads} bold-lead bullets`, why: "Every list as '**Label:** text' reads as generated; use prose or a table where it fits." });
|
package/skills/dogfood/SKILL.md
CHANGED
|
@@ -7,6 +7,8 @@ description: "Walk real product journeys, fix authorized problems and replay thr
|
|
|
7
7
|
|
|
8
8
|
Tests check what the author thought of. Dogfooding finds what a user hits. The output is fixed journeys, not a list of complaints.
|
|
9
9
|
|
|
10
|
+
`<skill-dir>` below means this skill's own folder. Run its scripts from the project root so output lands in the project, not in the installed skill.
|
|
11
|
+
|
|
10
12
|
## 0. Pick the mode
|
|
11
13
|
|
|
12
14
|
- **Diff-aware** (default on a branch): start from what the branch changed (`git diff origin/main...HEAD --stat`), map each change to the screens and journeys it touches, and test those first.
|
|
@@ -28,6 +30,7 @@ Use the browser session and account the user selected; never sign in as someone
|
|
|
28
30
|
- Real clicks and typing in a real browser, at real speed. No mocked responses, no seeding past the hard part.
|
|
29
31
|
- Follow through to the outcome: the record exists, the email arrived, the export opens and contains the right rows, the public page shows the change.
|
|
30
32
|
- Look at each screen. Capture the important ones at phone and desktop width with [`scripts/shoot.mjs`](scripts/shoot.mjs) when the URL is reachable without your session; use the browser tool's screenshots for signed-in states.
|
|
33
|
+
- When the journey waits on data, capture the waiting page and the settled page and compare them: `node <skill-dir>/scripts/shoot.mjs <waiting> --shift <settled> --widths 390 --themes light`. Exit 1 is a finding (a marked region or the document moved). Exit 2 means the regions were not marked, so the jump was not measured. Read the phone lines on that capture too: input size, zoom lock, ungated hover, selectable controls, tap highlight. A 390px picture can look aligned and still jump, zoom and stick. The lines that need a physical phone (a hover that stays after a tap, the notch, the keyboard) stay named as unverified.
|
|
31
34
|
- Keep the console and network panel open. A 500 behind a friendly toast is a finding.
|
|
32
35
|
- Note friction as well as failure: a step that needs thinking, a label that misleads, a wait with no feedback.
|
|
33
36
|
|
|
@@ -29,23 +29,43 @@ Options:
|
|
|
29
29
|
offsets depend on capture speed and are reported)
|
|
30
30
|
--click SELECTOR Click this element before capturing (e.g. to open a menu)
|
|
31
31
|
--scale N Device scale factor (default: 2)
|
|
32
|
+
--shift URL Load this second page at the same sizes. The first
|
|
33
|
+
page is the loading state, this one the settled state.
|
|
34
|
+
Reports regions (same id or data-region) whose box
|
|
35
|
+
moved by more than 1px, and any change in document
|
|
36
|
+
scroll height. Exits 1 when one did. Exits 2 when no
|
|
37
|
+
region was marked and the scroll height did not change,
|
|
38
|
+
which is not a pass. Settled shots are named with -settled.
|
|
32
39
|
--json Print a JSON manifest instead of text
|
|
33
40
|
|
|
34
41
|
Set SHOOT_CHROME_FLAGS for extra browser flags. --no-sandbox is added
|
|
35
42
|
automatically when CI is set or when running as root.
|
|
36
43
|
|
|
37
|
-
Also reports: horizontal overflow,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
Also reports, per capture: horizontal overflow, elements wider than the
|
|
45
|
+
viewport, stranded words in wrapped text, tap targets under 44px and
|
|
46
|
+
selectable control text (widths < 768), text under 12px, inputs under 16px,
|
|
47
|
+
a viewport that disables zoom, :hover rules outside @media (hover: hover),
|
|
48
|
+
a missing tap-highlight reset when the page has controls, console errors
|
|
49
|
+
and failed requests. Set CHROME_PATH to override browser discovery. The
|
|
50
|
+
temporary browser profile is deleted afterwards. Exits 1 if a page fails
|
|
51
|
+
to load, returns an HTTP error, or --shift finds a moved region or a
|
|
52
|
+
document scroll height change of more than 1px. Exits 2 when --shift could
|
|
53
|
+
not compare a marked region and the scroll height did not change. A bare
|
|
43
54
|
host:port (localhost:3000) is treated as http://.
|
|
55
|
+
The layout-shift entries on the performance timeline are not read. Headless
|
|
56
|
+
Chrome can report none of them for a jump that has already painted, so a
|
|
57
|
+
score of 0 would pass a page that moved. The two-page box compare is the check.
|
|
44
58
|
`;
|
|
45
59
|
|
|
60
|
+
function resolveTarget(value) {
|
|
61
|
+
if (/^[a-z]+:\/\//i.test(value)) return value;
|
|
62
|
+
return !existsSync(value) && /^(?:localhost|[\w-]+(?:\.[\w-]+)+|\d+\.\d+\.\d+\.\d+)(?::\d+)?(?:\/|$)|^[\w.-]+:\d+/.test(value)
|
|
63
|
+
? `http://${value}` : pathToFileURL(resolve(value)).href;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
function parse(argv) {
|
|
47
|
-
const o = { target: "", out: "shots", widths: [390, 1440], height: 0, themes: ["light", "dark"],
|
|
48
|
-
motion: "reduce", full: false, wait: 600, frames: 0, every: 100, click: "", scale: 2, json: false };
|
|
67
|
+
const o = { target: "", shift: "", out: "shots", widths: [390, 1440], height: 0, themes: ["light", "dark"],
|
|
68
|
+
motion: "reduce", full: false, wait: 600, frames: 0, every: 100, click: "", scale: 2, shotSuffix: "", json: false };
|
|
49
69
|
for (let i = 0; i < argv.length; i++) {
|
|
50
70
|
const a = argv[i], v = () => { const x = argv[++i]; if (x === undefined) throw new Error(`${a} needs a value`); return x; };
|
|
51
71
|
if (a === "--help" || a === "-h") { process.stdout.write(HELP); process.exit(0); }
|
|
@@ -60,14 +80,14 @@ function parse(argv) {
|
|
|
60
80
|
else if (a === "--every") o.every = Number(v());
|
|
61
81
|
else if (a === "--click") o.click = v();
|
|
62
82
|
else if (a === "--scale") o.scale = Number(v());
|
|
83
|
+
else if (a === "--shift") o.shift = v();
|
|
63
84
|
else if (a === "--json") o.json = true;
|
|
64
85
|
else if (a.startsWith("--")) throw new Error(`Unknown option ${a}`);
|
|
65
86
|
else o.target = a;
|
|
66
87
|
}
|
|
67
88
|
if (!o.target) { process.stdout.write(HELP); process.exit(1); }
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
? `http://${o.target}` : pathToFileURL(resolve(o.target)).href;
|
|
89
|
+
o.target = resolveTarget(o.target);
|
|
90
|
+
if (o.shift) o.shift = resolveTarget(o.shift);
|
|
71
91
|
if (o.frames) o.motion = "no-preference";
|
|
72
92
|
return o;
|
|
73
93
|
}
|
|
@@ -197,7 +217,7 @@ async function capture(cdp, o, width, theme) {
|
|
|
197
217
|
if (!o.frames) await sleep(o.wait);
|
|
198
218
|
}
|
|
199
219
|
const files = [], frameTimes = [];
|
|
200
|
-
const base = `${String(width)}-${theme}`;
|
|
220
|
+
const base = `${String(width)}-${theme}${o.shotSuffix || ""}`;
|
|
201
221
|
if (o.frames) {
|
|
202
222
|
// Capture first, measure the page afterwards, so frame 0 is as close to the trigger as possible.
|
|
203
223
|
for (let f = 0; f < o.frames; f++) {
|
|
@@ -277,8 +297,64 @@ async function capture(cdp, o, width, theme) {
|
|
|
277
297
|
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
278
298
|
if (size < 12) { tinyText.push(label(el) + " " + size + "px"); if (tinyText.length >= 5) break; }
|
|
279
299
|
}
|
|
300
|
+
const inputsUnder16 = [];
|
|
301
|
+
for (const el of document.querySelectorAll("input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=button]):not([type=submit]):not([type=reset]):not([type=file]):not([type=image]), textarea, select")) {
|
|
302
|
+
if (!visible(el)) continue;
|
|
303
|
+
const size = parseFloat(getComputedStyle(el).fontSize);
|
|
304
|
+
if (size < 16) { inputsUnder16.push((el.getAttribute("aria-label") || el.id || el.getAttribute("name") || el.getAttribute("placeholder") || el.tagName.toLowerCase()) + " " + (Math.round(size * 10) / 10) + "px"); if (inputsUnder16.length >= 8) break; }
|
|
305
|
+
}
|
|
306
|
+
const viewportMeta = document.querySelector('meta[name="viewport" i]')?.getAttribute("content") || "";
|
|
307
|
+
const zoomLocked = /user-scalable\\s*=\\s*(?:no|0)\\b|maximum-scale\\s*=\\s*1(?:\\.0+)?(?![.\\d])/i.test(viewportMeta);
|
|
308
|
+
const ungatedHovers = [];
|
|
309
|
+
let ungatedHoverCount = 0, tapHighlightDisabled = false;
|
|
310
|
+
const coversRoot = selector => selector.split(",").some(s => /^(?:html|:root|\\*|body)(?:\\s|$)/.test(s.trim()));
|
|
311
|
+
const visitRules = (rules, media) => {
|
|
312
|
+
if (!rules) return;
|
|
313
|
+
for (const rule of rules) {
|
|
314
|
+
if (rule.type === 4) visitRules(rule.cssRules, (media ? media + " " : "") + (rule.conditionText || ""));
|
|
315
|
+
else if (rule.type === 3 && rule.styleSheet) { try { visitRules(rule.styleSheet.cssRules, media); } catch (e) { /* unreadable import */ } }
|
|
316
|
+
else {
|
|
317
|
+
if (rule.type === 1 && rule.selectorText) {
|
|
318
|
+
if (/:hover\\b/.test(rule.selectorText) && !/\\(\\s*(?:any-)?hover\\s*:\\s*hover\\s*\\)/.test(media) && !/hover\\s*:\\s*none/.test(media)) {
|
|
319
|
+
ungatedHoverCount++;
|
|
320
|
+
if (ungatedHovers.length < 8) ungatedHovers.push(rule.selectorText.slice(0, 100));
|
|
321
|
+
}
|
|
322
|
+
const tap = rule.style && rule.style.getPropertyValue("-webkit-tap-highlight-color");
|
|
323
|
+
if (tap && /transparent|rgba\\(\\s*0\\s*,\\s*0\\s*,\\s*0\\s*,\\s*0\\s*\\)/i.test(tap) && coversRoot(rule.selectorText)) tapHighlightDisabled = true;
|
|
324
|
+
}
|
|
325
|
+
if (rule.cssRules && rule.type !== 4) visitRules(rule.cssRules, media);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const seenSheets = new Set();
|
|
330
|
+
for (const sheet of [...document.styleSheets, ...(document.adoptedStyleSheets || [])]) {
|
|
331
|
+
if (seenSheets.has(sheet)) continue;
|
|
332
|
+
seenSheets.add(sheet);
|
|
333
|
+
try { visitRules(sheet.cssRules, ""); } catch (e) { /* cross-origin sheet */ }
|
|
334
|
+
}
|
|
335
|
+
const inlineTap = document.documentElement.style.getPropertyValue("-webkit-tap-highlight-color");
|
|
336
|
+
if (inlineTap && /transparent/i.test(inlineTap)) tapHighlightDisabled = true;
|
|
337
|
+
const hasControls = !!document.querySelector("a[href], button, [role=button], input, select, textarea");
|
|
338
|
+
const selectableControls = [];
|
|
339
|
+
if (vw < 768) for (const el of document.querySelectorAll("button, [role=button]")) {
|
|
340
|
+
if (!visible(el) || !(el.innerText || "").trim()) continue;
|
|
341
|
+
const cs = getComputedStyle(el);
|
|
342
|
+
if ((cs.userSelect || cs.webkitUserSelect) !== "none") { selectableControls.push(label(el)); if (selectableControls.length >= 8) break; }
|
|
343
|
+
}
|
|
344
|
+
const regions = [], seenKeys = new Set();
|
|
345
|
+
for (const el of document.querySelectorAll("[id], [data-region]")) {
|
|
346
|
+
if (!visible(el)) continue;
|
|
347
|
+
const key = el.id ? "#" + el.id : "region:" + el.getAttribute("data-region");
|
|
348
|
+
if (seenKeys.has(key)) continue;
|
|
349
|
+
seenKeys.add(key);
|
|
350
|
+
const box = el.getBoundingClientRect();
|
|
351
|
+
regions.push({ key, top: Math.round(box.top), left: Math.round(box.left), width: Math.round(box.width), height: Math.round(box.height) });
|
|
352
|
+
if (regions.length >= 80) break;
|
|
353
|
+
}
|
|
280
354
|
return { scrollWidth: document.documentElement.scrollWidth, viewport: vw, overflowing: wide.map(w => w.text), stranded, smallTargets, tinyText,
|
|
281
|
-
height: document.documentElement.scrollHeight, title: document.title
|
|
355
|
+
height: document.documentElement.scrollHeight, title: document.title,
|
|
356
|
+
inputsUnder16, zoomLocked, ungatedHovers, ungatedHoverCount, tapHighlightDisabled: hasControls ? tapHighlightDisabled : null,
|
|
357
|
+
selectableControls, regions };
|
|
282
358
|
})()` });
|
|
283
359
|
if (!o.frames) {
|
|
284
360
|
const shot = await cdp.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: o.full });
|
|
@@ -287,10 +363,36 @@ async function capture(cdp, o, width, theme) {
|
|
|
287
363
|
}
|
|
288
364
|
const l = layout.result.value;
|
|
289
365
|
return { width, height, theme, motion: o.motion, files, ...(o.frames ? { frameTimesMs: frameTimes } : {}), title: l.title,
|
|
366
|
+
scrollHeight: l.height, regions: l.regions,
|
|
367
|
+
inputsUnder16: l.inputsUnder16, zoomLocked: l.zoomLocked, ungatedHovers: l.ungatedHovers, ungatedHoverCount: l.ungatedHoverCount,
|
|
368
|
+
tapHighlightDisabled: l.tapHighlightDisabled, selectableControls: l.selectableControls,
|
|
290
369
|
horizontalOverflow: l.scrollWidth > l.viewport ? `${l.scrollWidth - l.viewport}px` : null,
|
|
291
370
|
overflowingElements: l.overflowing, strandedWords: l.stranded, smallTapTargets: l.smallTargets, tinyText: l.tinyText, ...issues };
|
|
292
371
|
}
|
|
293
372
|
|
|
373
|
+
// Same id or data-region in both pages. A disappearing spinner is not a shift;
|
|
374
|
+
// a card that grows, or a control that moves, is.
|
|
375
|
+
function compareShift(before, after) {
|
|
376
|
+
const settled = new Map((after.regions || []).map(r => [r.key, r]));
|
|
377
|
+
const shifts = [];
|
|
378
|
+
let shared = 0;
|
|
379
|
+
for (const region of before.regions || []) {
|
|
380
|
+
const other = settled.get(region.key);
|
|
381
|
+
if (!other) continue;
|
|
382
|
+
shared++;
|
|
383
|
+
const dx = other.left - region.left, dy = other.top - region.top, dw = other.width - region.width, dh = other.height - region.height;
|
|
384
|
+
if (Math.abs(dx) > 1 || Math.abs(dy) > 1 || Math.abs(dw) > 1 || Math.abs(dh) > 1)
|
|
385
|
+
shifts.push({ key: region.key, before: region, after: other, dx, dy, dw, dh });
|
|
386
|
+
}
|
|
387
|
+
const scrollDelta = (after.scrollHeight ?? 0) - (before.scrollHeight ?? 0);
|
|
388
|
+
return {
|
|
389
|
+
scrollBefore: before.scrollHeight ?? null, scrollAfter: after.scrollHeight ?? null,
|
|
390
|
+
scrollDelta: Math.abs(scrollDelta) > 1 ? scrollDelta : 0, shared, shifts,
|
|
391
|
+
settledFiles: after.files,
|
|
392
|
+
...(shared === 0 ? { note: "no shared id or data-region; only the document scroll height was compared" } : {}),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
294
396
|
async function main() {
|
|
295
397
|
const o = parse(process.argv.slice(2));
|
|
296
398
|
mkdirSync(o.out, { recursive: true });
|
|
@@ -300,15 +402,31 @@ async function main() {
|
|
|
300
402
|
for (const width of o.widths) for (const theme of o.themes) {
|
|
301
403
|
const cdp = await connect(browser.port);
|
|
302
404
|
await cdp.send("Page.enable"); await cdp.send("Runtime.enable"); await cdp.send("Network.enable");
|
|
303
|
-
|
|
405
|
+
const before = await capture(cdp, o, width, theme);
|
|
304
406
|
cdp.close();
|
|
407
|
+
if (o.shift) {
|
|
408
|
+
const settled = await connect(browser.port);
|
|
409
|
+
await settled.send("Page.enable"); await settled.send("Runtime.enable"); await settled.send("Network.enable");
|
|
410
|
+
const after = await capture(settled, { ...o, target: o.shift, shotSuffix: "-settled" }, width, theme);
|
|
411
|
+
settled.close();
|
|
412
|
+
before.shift = compareShift(before, after);
|
|
413
|
+
if (after.loadError && !before.loadError) before.loadError = `settled page: ${after.loadError}`;
|
|
414
|
+
}
|
|
415
|
+
results.push(before);
|
|
305
416
|
}
|
|
306
417
|
} finally { await browser.close(); }
|
|
307
418
|
const failed = results.some(r => r.loadError);
|
|
308
|
-
|
|
419
|
+
const shifted = results.some(r => r.shift && (r.shift.shifts.length > 0 || r.shift.scrollDelta));
|
|
420
|
+
// No marked region and no scroll change means the jump was not measured.
|
|
421
|
+
// Scroll height stays at the viewport when both states fit, so that case must not pass.
|
|
422
|
+
const unchecked = results.some(r => r.shift && r.shift.shared === 0 && !r.shift.scrollDelta);
|
|
423
|
+
if (failed || shifted) process.exitCode = 1;
|
|
424
|
+
else if (unchecked) process.exitCode = 2;
|
|
309
425
|
if (o.json) { process.stdout.write(JSON.stringify({ target: o.target, captures: results }, null, 2) + "\n"); return; }
|
|
310
426
|
for (const r of results) {
|
|
311
|
-
const
|
|
427
|
+
const shiftFlag = r.shift && (r.shift.shifts.length > 0 || r.shift.scrollDelta) ? "layout shift" : "";
|
|
428
|
+
const uncheckedFlag = r.shift && r.shift.shared === 0 && !r.shift.scrollDelta ? "shift not checked" : "";
|
|
429
|
+
const flags = [r.loadError, shiftFlag, uncheckedFlag, r.horizontalOverflow && `horizontal overflow ${r.horizontalOverflow}`,
|
|
312
430
|
r.consoleErrors.length && `${r.consoleErrors.length} console error(s)`,
|
|
313
431
|
r.failedRequests.length && `${r.failedRequests.length} failed request(s)`].filter(Boolean);
|
|
314
432
|
process.stdout.write(`${r.width}px ${r.theme}: ${r.files.join(", ")}${flags.length ? " !! " + flags.join("; ") : ""}\n`);
|
|
@@ -316,6 +434,20 @@ async function main() {
|
|
|
316
434
|
for (const e of r.strandedWords) process.stdout.write(` stranded words: ${e}\n`);
|
|
317
435
|
for (const e of r.smallTapTargets) process.stdout.write(` tap target < 44px: ${e}\n`);
|
|
318
436
|
for (const e of r.tinyText) process.stdout.write(` text < 12px: ${e}\n`);
|
|
437
|
+
for (const e of r.inputsUnder16 || []) process.stdout.write(` input under 16px: ${e}\n`);
|
|
438
|
+
if (r.zoomLocked) process.stdout.write(` viewport disables zoom\n`);
|
|
439
|
+
if (r.ungatedHoverCount) process.stdout.write(` hover not gated (${r.ungatedHoverCount}): ${(r.ungatedHovers || []).join("; ")}\n`);
|
|
440
|
+
if (r.tapHighlightDisabled === false) process.stdout.write(` tap highlight not disabled\n`);
|
|
441
|
+
for (const e of r.selectableControls || []) process.stdout.write(` control text selectable: ${e}\n`);
|
|
442
|
+
if (r.shift) {
|
|
443
|
+
if (r.shift.note) process.stdout.write(` shift: ${r.shift.note}\n`);
|
|
444
|
+
if (r.shift.scrollDelta) process.stdout.write(` document scroll height ${r.shift.scrollDelta > 0 ? "+" : ""}${r.shift.scrollDelta}px after load\n`);
|
|
445
|
+
for (const s of r.shift.shifts.slice(0, 12)) {
|
|
446
|
+
const part = (n, label) => n ? `${label} ${n > 0 ? "+" : ""}${n}px` : "";
|
|
447
|
+
const bits = [part(s.dy, "top"), part(s.dh, "height"), part(s.dx, "left"), part(s.dw, "width")].filter(Boolean);
|
|
448
|
+
process.stdout.write(` layout shift ${s.key}: ${bits.join(", ")}\n`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
319
451
|
for (const e of [...new Set(r.consoleErrors)].slice(0, 5)) process.stdout.write(` console: ${e}\n`);
|
|
320
452
|
for (const e of [...new Set(r.failedRequests)].slice(0, 5)) process.stdout.write(` request: ${e}\n`);
|
|
321
453
|
}
|
package/skills/launch/SKILL.md
CHANGED
|
@@ -32,7 +32,7 @@ Fix before announcing:
|
|
|
32
32
|
Put it in one folder (`launch/` or `docs/launch/`). Contents and formats in [kit](references/kit.md):
|
|
33
33
|
|
|
34
34
|
- **Share card** rendered from HTML/SVG source with `render-gate.mjs`, committed with its source. The gate fails a card whose font fell back, whose copy drifted from `copy.md`, or whose text is cropped.
|
|
35
|
-
- **Demo media**: 3 to 5 screenshots at real sizes, and a 6 to 15 second clip of the core action if motion helps. Build the clip as an HTML composition and render it with `film.mjs`, so a fix is an edit and a re-render ([film](references/film.md)).
|
|
35
|
+
- **Demo media**: 3 to 5 screenshots at real sizes, and a 6 to 15 second clip of the core action if motion helps. Build the clip as an HTML composition and render it with `film.mjs`, so a fix is an edit and a re-render ([film](references/film.md)). The product in a card, bento, device or clip is a capture from the live product or from `shoot.mjs`, placed as an image. Draw the frame. Do not draw the screen. Every number sits in that capture or in the agreed copy file. A number from a marketing note that is not in the copy file does not appear. Framing rules are in [kit](references/kit.md).
|
|
36
36
|
- **Copy**: one-line description (60 characters or fewer), a short paragraph, a longer description, the X/LinkedIn post, a Show HN or community post if it fits, a changelog entry. Rules in [copy](references/copy.md).
|
|
37
37
|
- **Tracker**: a CSV of where you will post or submit.
|
|
38
38
|
|