@mthanh/vespercli 2.2.0 → 2.3.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/README.md +6 -3
- package/assets/CATALOG.md +9 -2
- package/assets/claude/.claude-plugin/marketplace.json +9 -9
- package/assets/claude/.claude-plugin/plugin.json +1 -1
- package/assets/claude/agents/code-planner.md +1 -1
- package/assets/claude/agents/code-reviewer.md +1 -1
- package/assets/claude/agents/code-tester.md +1 -1
- package/assets/claude/agents/copywriter.md +1 -1
- package/assets/claude/agents/feature-wrap-document.md +1 -1
- package/assets/claude/agents/janitor.md +1 -1
- package/assets/claude/agents/modernization.md +1 -1
- package/assets/claude/agents/principal-software-engineer.md +1 -1
- package/assets/claude/agents/refine-issue.md +1 -1
- package/assets/claude/agents/se-system-architecture-reviewer.md +1 -1
- package/assets/claude/agents/se-technical-writer.md +1 -1
- package/assets/claude/agents/skill-creator.md +1 -1
- package/assets/claude/agents/task-researcher.md +1 -1
- package/assets/claude/agents/technical-product-manager.md +1 -1
- package/assets/claude/agents/vision-analyst.md +1 -1
- package/assets/claude/agents/vitest.md +1 -1
- package/assets/claude/agents/workspace-learner.md +2 -2
- package/assets/claude/packs/backend/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/backend/agents/firebase-developer.md +1 -1
- package/assets/claude/packs/backend/agents/nodejs-express-developer.md +1 -1
- package/assets/claude/packs/docs/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/frontend/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/frontend/agents/expert-nextjs-developer.md +1 -1
- package/assets/claude/packs/frontend/agents/expert-react-frontend-engineer.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-component-analyzer.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-scenario-scanner.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-test-generator.md +1 -1
- package/assets/claude/packs/frontend/agents/jest-react-test-planner.md +1 -1
- package/assets/claude/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
- package/assets/claude/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
- package/assets/claude/packs/graphify/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/integrations/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/playwright/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/playwright/agents/playwright-component-analyzer.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-scenario-scanner.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-test-generator.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-test-planner.md +1 -1
- package/assets/claude/packs/playwright/agents/playwright-tester.md +1 -1
- package/assets/claude/packs/polyglot/.claude-plugin/plugin.json +1 -1
- package/assets/claude/packs/styles/.claude-plugin/plugin.json +1 -1
- package/assets/copilot/agents/code-planner.agent.md +1 -1
- package/assets/copilot/agents/code-reviewer.agent.md +1 -1
- package/assets/copilot/agents/code-tester.agent.md +1 -1
- package/assets/copilot/agents/copywriter.agent.md +1 -1
- package/assets/copilot/agents/feature-wrap-document.agent.md +1 -1
- package/assets/copilot/agents/janitor.agent.md +1 -1
- package/assets/copilot/agents/modernization.agent.md +1 -1
- package/assets/copilot/agents/principal-software-engineer.agent.md +1 -1
- package/assets/copilot/agents/refine-issue.agent.md +1 -1
- package/assets/copilot/agents/se-system-architecture-reviewer.agent.md +1 -1
- package/assets/copilot/agents/se-technical-writer.agent.md +1 -1
- package/assets/copilot/agents/skill-creator.agent.md +1 -1
- package/assets/copilot/agents/task-researcher.agent.md +1 -1
- package/assets/copilot/agents/technical-product-manager.agent.md +1 -1
- package/assets/copilot/agents/vision-analyst.agent.md +1 -1
- package/assets/copilot/agents/vitest.agent.md +1 -1
- package/assets/copilot/agents/workspace-learner.agent.md +2 -2
- package/assets/copilot/packs/backend/agents/firebase-developer.agent.md +1 -1
- package/assets/copilot/packs/backend/agents/nodejs-express-developer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/expert-nextjs-developer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/expert-react-frontend-engineer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-component-analyzer.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-scenario-scanner.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-test-generator.agent.md +1 -1
- package/assets/copilot/packs/frontend/agents/jest-react-test-planner.agent.md +1 -1
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/LICENSE +21 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/SKILL.md +529 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/UPSTREAM.json +7 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/app.css +216 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.css +105 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/base.js +37 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/charts.js +617 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.css +28 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/assets/scenes/stage.js +133 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/evals/evals.json +76 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/app.json +1327 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/directions.json +1078 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/heroes.json +1086 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/motions.json +92 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/palettes.json +1485 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/photos.json +7240 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sections.json +4429 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/sites.json +8350 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/library/type.json +2024 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/app-screens.md +1199 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/colour.md +141 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/copy.md +177 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/critique.md +182 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/imagery.md +791 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/layout.md +292 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/motion.md +980 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/research.md +356 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/scenes.md +174 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/tells.md +115 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/references/type.md +170 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/assets.mjs +865 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/draw.mjs +584 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/inspect.mjs +303 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/app-draw.mjs +203 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/cdp.mjs +248 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/probe.mjs +909 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lib/rules.mjs +243 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/lint.mjs +119 -0
- package/assets/copilot/packs/frontend/skills/design-taste-frontend/scripts/scenes.mjs +124 -0
- package/assets/copilot/packs/playwright/agents/playwright-component-analyzer.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-scenario-scanner.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-test-generator.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-test-planner.agent.md +1 -1
- package/assets/copilot/packs/playwright/agents/playwright-tester.agent.md +1 -1
- package/assets/manifest.json +2 -0
- package/dist/cli.min.js +102 -102
- package/package.json +1 -1
- package/scripts/__snapshots__/generate-claude-assets.test.js.snap +1 -1
- package/scripts/generate-claude-assets.strict.test.js +1 -1
- package/scripts/generate-claude-assets.test.js +6 -6
- package/scripts/packs-layout.test.js +2 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tasteskill / inspect — render the page in a real browser, measure it, and photograph it.
|
|
3
|
+
//
|
|
4
|
+
// node inspect.mjs <url | path/to/file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]
|
|
5
|
+
//
|
|
6
|
+
// --film also photographs the scroll as a visitor sees it (a frame every third of a screen, real wheel
|
|
7
|
+
// input) into desktop-film-NN.jpg, so a scroll scene is judged by its in-between states. A direction
|
|
8
|
+
// that dealt scroll scenes switches it on by itself.
|
|
9
|
+
//
|
|
10
|
+
// --app judges an app screen (dashboard, admin, settings) by the rules in references/app-screens.md; a
|
|
11
|
+
// direction dealt with draw.mjs --app switches it on by itself.
|
|
12
|
+
//
|
|
13
|
+
// Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done).
|
|
14
|
+
// Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { resolve, join, relative, dirname, extname, sep } from 'node:path';
|
|
17
|
+
import { createServer } from 'node:http';
|
|
18
|
+
import { Browser, sleep } from './lib/cdp.mjs';
|
|
19
|
+
import { probe, frame, layout, app, colourShare } from './lib/probe.mjs';
|
|
20
|
+
import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs';
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; };
|
|
24
|
+
const has = (name) => { const i = args.indexOf('--' + name); if (i === -1) return false; args.splice(i, 1); return true; };
|
|
25
|
+
const outDir = resolve(flag('out', '.taste-skill/inspect'));
|
|
26
|
+
const widths = flag('widths', '1440,768,390').split(',').map(Number).filter(Boolean);
|
|
27
|
+
let allow = flag('allow', '').split(',').filter(Boolean);
|
|
28
|
+
const asJson = has('json');
|
|
29
|
+
let appMode = has('app');
|
|
30
|
+
let film = has('film');
|
|
31
|
+
let dealtFonts = []; // the faces the saved sheet dealt, to catch a build that quietly fell back to a default
|
|
32
|
+
const target = args[0];
|
|
33
|
+
if (!target || target === '-h' || target.startsWith('--')) {
|
|
34
|
+
console.error('usage: node inspect.mjs <url | file.html> [--out DIR] [--widths 1440,768,390] [--allow rule,rule] [--app] [--film] [--json]');
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
// Allowances granted by the drawn direction (written by draw.mjs) apply automatically.
|
|
38
|
+
for (const p of ['.taste-skill/direction.json', join(outDir, '..', 'direction.json')]) {
|
|
39
|
+
if (existsSync(p)) { try { const d = JSON.parse(readFileSync(p, 'utf8')); allow = [...new Set([...allow, ...(d.allow || [])])]; if (d.kind === 'app') appMode = true; if (Array.isArray(d.scenes) && d.scenes.length) film = true; dealtFonts = (d.input?.keep || []).includes('type') ? [] : d.kind === 'app' ? [d.style?.tokens?.font?.family].filter(Boolean) : [d.type?.display?.family, d.type?.text?.family].filter(Boolean); } catch {} break; }
|
|
40
|
+
}
|
|
41
|
+
// A local file is served over http from a throwaway server on 127.0.0.1, so the browser never has to
|
|
42
|
+
// open a file path (some sandboxes show Node and the browser different views of the disk) and relative
|
|
43
|
+
// assets, fetch() and module scripts behave as they will when the page is hosted.
|
|
44
|
+
let url = target; let server = null;
|
|
45
|
+
if (!/^https?:\/\//.test(target)) {
|
|
46
|
+
const file = resolve(target);
|
|
47
|
+
if (!existsSync(file)) { console.error('No such file: ' + file); process.exit(2); }
|
|
48
|
+
const cwd = resolve('.');
|
|
49
|
+
const root = file.startsWith(cwd + sep) ? cwd : dirname(file);
|
|
50
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' };
|
|
51
|
+
server = createServer((req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
let f = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname)));
|
|
54
|
+
if (f !== root && !f.startsWith(root + sep)) { res.writeHead(403); return res.end(); }
|
|
55
|
+
if (statSync(f).isDirectory()) f = join(f, 'index.html');
|
|
56
|
+
res.writeHead(200, { 'content-type': MIME[extname(f).toLowerCase()] || 'application/octet-stream', 'content-length': statSync(f).size, 'cache-control': 'no-store' });
|
|
57
|
+
createReadStream(f).pipe(res);
|
|
58
|
+
} catch { res.writeHead(404); res.end('not found'); }
|
|
59
|
+
});
|
|
60
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
61
|
+
url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`;
|
|
62
|
+
}
|
|
63
|
+
const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 };
|
|
64
|
+
const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile');
|
|
65
|
+
|
|
66
|
+
mkdirSync(outDir, { recursive: true });
|
|
67
|
+
// Screenshots from an earlier run would sit beside the new ones when the page got shorter.
|
|
68
|
+
for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+|film-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f));
|
|
69
|
+
const browser = await Browser.launch();
|
|
70
|
+
const helper = await browser.newPage({ width: 1600, height: 1000 });
|
|
71
|
+
const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] };
|
|
72
|
+
|
|
73
|
+
const MOTION_MIN = 8, MOTION_GOOD = 16; // distinct moving elements over load + one scroll through the page
|
|
74
|
+
// Runs in the page before any of its own scripts: counts the distinct elements that actually move while
|
|
75
|
+
// the page loads and is scrolled (CSS animations, CSS transitions, Web Animations, and script-driven
|
|
76
|
+
// transform/opacity changes such as GSAP), so a page that never moves can be told from one that does.
|
|
77
|
+
const MOTION_RECORDER = `(() => {
|
|
78
|
+
const seen = new WeakSet(); const c = { elements: 0, css: 0, transitions: 0, scripted: 0 };
|
|
79
|
+
const note = (t, k) => { if (!(t instanceof Element) || seen.has(t)) return; seen.add(t); c.elements++; c[k]++; };
|
|
80
|
+
addEventListener('animationstart', (e) => note(e.target, 'css'), true);
|
|
81
|
+
addEventListener('transitionrun', (e) => note(e.target, 'transitions'), true);
|
|
82
|
+
const watch = () => new MutationObserver((list) => { for (const m of list) { const s = m.target.getAttribute && m.target.getAttribute('style') || ''; if (/transform|translate|opacity|scale|rotate|clip-path/.test(s)) note(m.target, 'scripted'); } })
|
|
83
|
+
.observe(document.documentElement, { attributes: true, attributeFilter: ['style'], subtree: true });
|
|
84
|
+
if (document.documentElement) watch(); else addEventListener('DOMContentLoaded', watch);
|
|
85
|
+
window.__motionSample = () => { for (const a of document.getAnimations ? document.getAnimations() : []) { const t = a.effect && a.effect.target; if (t) note(t, a.constructor && a.constructor.name === 'CSSTransition' ? 'transitions' : 'css'); } };
|
|
86
|
+
window.__motion = () => { window.__motionSample(); const v = [...document.querySelectorAll('video')]; return { ...c, videos: v.length, playingVideos: v.filter((x) => x.autoplay || !x.paused).length }; };
|
|
87
|
+
})();`;
|
|
88
|
+
|
|
89
|
+
// Is the page's title on screen and readable: in the viewport, not faded, not masked away?
|
|
90
|
+
const TITLE_SEEN = `(() => {
|
|
91
|
+
const h = document.querySelector('h1'); if (!h) return { found: false };
|
|
92
|
+
const r = h.getBoundingClientRect();
|
|
93
|
+
if (r.width < 2 || r.height < 2) return { found: true, visible: false, why: 'it has no size yet' };
|
|
94
|
+
if (r.bottom < 0 || r.top > innerHeight) return { found: false }; // a title below the first screen is the no-title check's business
|
|
95
|
+
let o = 1; for (let p = h; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden' || cs.display === 'none') return { found: true, visible: false, why: 'it is hidden' }; o *= +cs.opacity; }
|
|
96
|
+
if (o < 0.5) return { found: true, visible: false, why: 'it is at ' + Math.round(o * 100) + '% opacity' };
|
|
97
|
+
// text moved out of its own masked line, or a cover lying over it, shows up as nothing at its centre
|
|
98
|
+
const range = document.createRange(); range.selectNodeContents(h); const rr = range.getBoundingClientRect();
|
|
99
|
+
const x = Math.min(innerWidth - 1, Math.max(0, rr.left + rr.width / 2)), y = Math.min(innerHeight - 1, Math.max(0, rr.top + Math.min(rr.height, 80) / 2));
|
|
100
|
+
const hit = document.elementFromPoint(x, y);
|
|
101
|
+
if (hit && !h.contains(hit) && !hit.contains(h)) { const cs = getComputedStyle(hit); if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || /^(IMG|VIDEO|CANVAS)$/.test(hit.tagName)) return { found: true, visible: false, why: 'something lies over it' }; }
|
|
102
|
+
return { found: true, visible: true };
|
|
103
|
+
})()`;
|
|
104
|
+
|
|
105
|
+
async function sheet(frames, cols, cellW, cellH, path) {
|
|
106
|
+
const rows = Math.ceil(frames.length / cols);
|
|
107
|
+
await helper.setViewport({ width: cols * cellW, height: rows * cellH, dsf: 1 });
|
|
108
|
+
await helper.eval((a) => {
|
|
109
|
+
document.body.style.cssText = `margin:0;background:#888;display:grid;grid-template-columns:repeat(${a.cols},${a.cellW}px)`;
|
|
110
|
+
document.body.innerHTML = a.frames.map((s) => `<img src="data:image/jpeg;base64,${s}" style="width:${a.cellW}px;height:${a.cellH}px;display:block;outline:1px solid #888">`).join('');
|
|
111
|
+
return Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
|
|
112
|
+
}, { frames, cols, cellW, cellH });
|
|
113
|
+
await helper.screenshot({ path, quality: 76 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
for (const w of widths) {
|
|
118
|
+
const h = HEIGHT[w] || Math.round(w * 0.625);
|
|
119
|
+
const label = name(w);
|
|
120
|
+
const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 });
|
|
121
|
+
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {});
|
|
122
|
+
const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 });
|
|
123
|
+
await sleep(600); // about 1.2s after the load event: is the title there to read yet?
|
|
124
|
+
const early = await page.eval(TITLE_SEEN).catch(() => null);
|
|
125
|
+
await page.eval('document.fonts.ready.then(() => true)').catch(() => {});
|
|
126
|
+
await sleep(800); // let the load choreography finish before judging the first screen
|
|
127
|
+
const fold = join(outDir, `${label}-fold.jpg`);
|
|
128
|
+
await page.screenshot({ path: fold, quality: 82 });
|
|
129
|
+
const metrics = await page.eval(probe);
|
|
130
|
+
|
|
131
|
+
// walk the page one screen at a time so scroll reveals fire, photographing each screen
|
|
132
|
+
const frames = [readFileSync(fold).toString('base64')];
|
|
133
|
+
const frameChecks = [await page.eval(frame).catch(() => null)];
|
|
134
|
+
const screens = Math.min(11, Math.max(0, Math.ceil(metrics.page.height / h) - 1));
|
|
135
|
+
for (let i = 1; i <= screens; i++) {
|
|
136
|
+
await page.eval(`window.scrollTo({ top: ${i * h}, behavior: 'instant' })`).catch(() => {});
|
|
137
|
+
await page.wheel(2);
|
|
138
|
+
await sleep(650);
|
|
139
|
+
frames.push(await page.screenshot({ quality: 78 }));
|
|
140
|
+
frameChecks.push(await page.eval(frame).catch(() => null));
|
|
141
|
+
await page.eval('window.__motionSample && window.__motionSample()').catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
const motion = await page.eval('window.__motion ? window.__motion() : null').catch(() => null);
|
|
144
|
+
// re-measure after everything has revealed (catches content that only exists post-reveal)
|
|
145
|
+
await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
|
|
146
|
+
await sleep(350);
|
|
147
|
+
const after = await page.eval(probe).catch(() => metrics);
|
|
148
|
+
const m = after.headings.length >= metrics.headings.length ? after : metrics;
|
|
149
|
+
const lay = await page.eval(layout).catch(() => null);
|
|
150
|
+
const appM = appMode ? await page.eval(app).catch(() => null) : null;
|
|
151
|
+
if (appM) appM.colourShare = await helper.eval(colourShare, 'data:image/jpeg;base64,' + frames[0]).catch(() => null);
|
|
152
|
+
|
|
153
|
+
// scroll scenes (assets/scenes): each must have started, run live on desktop, and leave the page room
|
|
154
|
+
const sceneState = w >= 1100 && !appMode ? await page.eval(`[...document.querySelectorAll('section.scene, .scene[data-scene]')].map((s) => ({ id: s.dataset.scene || s.id || 'scene', live: s.classList.contains('is-live'), rest: s.classList.contains('is-rest'), h: Math.round(s.getBoundingClientRect().height) }))`).catch(() => []) : [];
|
|
155
|
+
const sceneFindings = [];
|
|
156
|
+
if (sceneState.length) {
|
|
157
|
+
const idle = sceneState.filter((s) => !s.live && !s.rest);
|
|
158
|
+
if (idle.length && !allow.includes('scene-not-started')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-started', width: w, message: `${idle.length} scene(s) never started: ${idle.map((s) => s.id).join(', ')}.`, fix: 'Load assets/scenes/stage.js and the scene file for each data-scene, then call Stage.smooth(); Stage.init(); at the end of <body> (references/scenes.md, "Putting a scene on a page").' });
|
|
159
|
+
const atRest = sceneState.filter((s) => s.rest);
|
|
160
|
+
if (atRest.length && !allow.includes('scene-not-live')) sceneFindings.push({ level: 'FAIL', id: 'scene-not-live', width: w, message: `${atRest.length} scene(s) show their finished composition on desktop instead of running: ${atRest.map((s) => s.id).join(', ')}.`, fix: 'GSAP or ScrollTrigger did not load (check the pinned CDN lines and the console), or the scene threw while building. The scene must run where motion is allowed.' });
|
|
161
|
+
if (sceneState.length > 4 && !allow.includes('many-scenes')) sceneFindings.push({ level: 'WARN', id: 'many-scenes', width: w, message: `${sceneState.length} scroll scenes on one page.`, fix: 'Two or three scenes, with quiet sections between them, so each one lands. Cut the weakest.' });
|
|
162
|
+
const held = sceneState.reduce((a, s) => a + Math.max(0, s.h - h), 0);
|
|
163
|
+
if (held > (metrics.page.height || 1) * 0.6 && !allow.includes('scene-heavy')) sceneFindings.push({ level: 'WARN', id: 'scene-heavy', width: w, message: `Scenes hold ${Math.round(held / h)} of the page's ${Math.round((metrics.page.height || 0) / h)} screens.`, fix: 'Shorten the holds (one screen per beat) or cut a scene; the reading sections need room between them.' });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --film: the scroll as a visitor sees it. Real wheel input and a frame every third of a screen, so a
|
|
167
|
+
// scroll scene shows its in-between states, not only where each screen lands.
|
|
168
|
+
const filmSheets = [];
|
|
169
|
+
if (film && w >= 1100) {
|
|
170
|
+
await page.eval(`window.scrollTo({ top: 0, behavior: 'instant' })`).catch(() => {});
|
|
171
|
+
await sleep(700);
|
|
172
|
+
const shots = []; let lastY = -1, still = 0;
|
|
173
|
+
for (let i = 0; i < 96; i++) {
|
|
174
|
+
shots.push(await page.screenshot({ quality: 70 }));
|
|
175
|
+
await page.wheel(Math.round(h / 3), { step: 100, pauseMs: 16 });
|
|
176
|
+
await sleep(420);
|
|
177
|
+
const y = await page.eval('Math.round(scrollY || document.scrollingElement.scrollTop || 0)').catch(() => -1);
|
|
178
|
+
if (y === lastY) { if (++still >= 2) break; } else still = 0;
|
|
179
|
+
lastY = y;
|
|
180
|
+
}
|
|
181
|
+
for (let i = 0; i < shots.length; i += 24) {
|
|
182
|
+
const p = join(outDir, `${label}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`);
|
|
183
|
+
await sheet(shots.slice(i, i + 24), 6, 320, 200, p);
|
|
184
|
+
filmSheets.push(p);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const cols = w >= 1100 ? 2 : w >= 600 ? 3 : 4;
|
|
189
|
+
const cellW = w >= 1100 ? 800 : w >= 600 ? 512 : 390;
|
|
190
|
+
const cellH = Math.round((cellW * h) / w);
|
|
191
|
+
const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8;
|
|
192
|
+
const sheets = [];
|
|
193
|
+
for (let i = 0; i < frames.length; i += perSheet) {
|
|
194
|
+
const p = join(outDir, `${label}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`);
|
|
195
|
+
await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
|
|
196
|
+
sheets.push(p);
|
|
197
|
+
}
|
|
198
|
+
let findings = [...judge(m, { width: w, allow }), ...sceneFindings];
|
|
199
|
+
if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) {
|
|
200
|
+
const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim();
|
|
201
|
+
const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family));
|
|
202
|
+
const missing = dealtFonts.filter((f) => !used.some((u) => u === norm(f) || u.startsWith(norm(f))));
|
|
203
|
+
if (missing.length === dealtFonts.length) findings.push({ level: appMode ? 'FAIL' : 'WARN', id: 'font-not-dealt', width: w, message: `The sheet dealt ${dealtFonts.join(' and ')}, but the page renders ${m.type.families.slice(0, 2).map((f) => f.family).join(', ')}.`, fix: 'Load the dealt face (the link on the sheet) and put it first in font-family. A build that falls back to a default face lands on the default look.' });
|
|
204
|
+
}
|
|
205
|
+
// a third of the studied award pages were still unreadable 4.5s after load; the house sequence is over by 1.2s
|
|
206
|
+
if (!appMode && early && early.found && !early.visible && !allow.includes('late-title')) findings.push({ level: 'FAIL', id: 'late-title', width: w, message: `The title is not readable 1.2s after the page loaded (${early.why}).`, fix: 'Put the headline in the HTML, painted on the first frame; the load sequence only adds arrival and is settled by 1.2s (references/motion.md, "Load sequence in 1.2 seconds"). No preloader, no gate, no waiting for the load event.' });
|
|
207
|
+
if (lay) findings.push(...judgeLayout(lay, { width: w, allow }));
|
|
208
|
+
if (appMode) {
|
|
209
|
+
findings = findings.filter((f) => !APP_SKIP.has(f.id) && f.id !== 'no-title' && f.id !== 'small-title');
|
|
210
|
+
if (appM) findings.push(...judgeApp(appM, m, { width: w, allow }));
|
|
211
|
+
}
|
|
212
|
+
// what only shows up while scrolling: text hidden under something, and screens with almost nothing on them
|
|
213
|
+
const coveredText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => f.covered.map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
|
|
214
|
+
if (coveredText.length && !allow.includes('covered-text')) findings.push({ level: 'FAIL', id: 'covered-text', width: w, message: `Large text lies under another element at some scroll position: ` + coveredText.slice(0, 4).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% hidden)`).join('; '), fix: 'Nothing may sit on top of a headline or statement. Move the picture, shorten the pinned run, or raise the text above it (z-index) and give it a ground.' });
|
|
215
|
+
const clippedText = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.clipped || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
|
|
216
|
+
if (clippedText.length && !allow.includes('clipped-text')) findings.push({ level: 'FAIL', id: 'clipped-text', width: w, message: 'Large text is cut off along its top by the box that holds it: ' + clippedText.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen})`).join('; '), fix: 'Jurors read a wordmark sliced at the top as broken. Give the box room for the ascenders (line-height and padding), or crop at the bottom or a side edge on purpose.' });
|
|
217
|
+
const overPictures = [...new Map(frameChecks.filter(Boolean).flatMap((f, i) => (f.pinnedOver || []).map((c) => [c.text, { ...c, screen: i + 1 }]))).values()];
|
|
218
|
+
if (overPictures.length && !allow.includes('pinned-over-picture')) findings.push({ level: 'WARN', id: 'pinned-over-picture', width: w, message: 'Pinned large text slides across pictures while the page scrolls: ' + overPictures.slice(0, 3).map((c) => `"${c.text}" (screen ${c.screen}, ${Math.round(c.share * 100)}% over a picture)`).join('; '), fix: 'Jurors read a pinned title crossing drawings or photographs as a collision. Keep the pictures in the column the title does not occupy, or unpin it.' });
|
|
219
|
+
const empty = frameChecks.map((f, i) => ({ f, i })).filter(({ f }) => f && f.filled < 0.2);
|
|
220
|
+
// the same large statement on screen for three screens or more in a row
|
|
221
|
+
const runs = new Map();
|
|
222
|
+
frameChecks.forEach((f, i) => { for (const t of f?.big || []) { const r = runs.get(t) || { start: i, last: i - 1, best: 0 }; if (r.last === i - 1) r.last = i; else { r.start = i; r.last = i; } r.best = Math.max(r.best, r.last - r.start + 1); runs.set(t, r); } });
|
|
223
|
+
const longPins = [...runs.entries()].filter(([, r]) => r.best >= 3);
|
|
224
|
+
if (w >= 1100 && longPins.length && !allow.includes('long-pin')) findings.push({ level: 'WARN', id: 'long-pin', width: w, message: `The same large text stays on screen for ${Math.max(...longPins.map(([, r]) => r.best))} screens in a row: ` + longPins.slice(0, 2).map(([t]) => `"${t}"`).join('; '), fix: 'A pinned element earns two screens, and something must change at each. Shorten the run or give every screen its own content.' });
|
|
225
|
+
// a page that barely moves: counted from what really animated while it loaded and was scrolled
|
|
226
|
+
if (w >= 1100 && motion && !allow.includes('still-page')) {
|
|
227
|
+
const moving = motion.elements + motion.playingVideos * 4;
|
|
228
|
+
if (moving < MOTION_MIN) findings.push({ level: 'FAIL', id: 'still-page', width: w, message: `Only ${motion.elements} element(s) moved while the page loaded and scrolled${motion.playingVideos ? ` (plus ${motion.playingVideos} playing video)` : ''}.`, fix: 'Build the motion layer (SKILL.md step 9, references/motion.md): the signature move, an entrance for the parts of every section, feedback on every control, and one moving picture. Transform and opacity only, with a reduced-motion fallback.' });
|
|
229
|
+
else if (moving < MOTION_GOOD) findings.push({ level: 'WARN', id: 'still-page', width: w, message: `Little motion: ${motion.elements} element(s) moved while the page loaded and scrolled.`, fix: 'Give every section an entrance for its parts (not one fade of the whole block) and add one moving picture: a looping clip or a slow move on a photograph.' });
|
|
230
|
+
}
|
|
231
|
+
if (w >= 1100 && empty.length >= 2 && !allow.includes('empty-screens')) findings.push({ level: 'FAIL', id: 'empty-screens', width: w, message: `${empty.length} of ${frameChecks.length} desktop screens are almost empty ground (under 20% of the screen holds text, a picture or a colour field): screens ${empty.map(({ i }) => i + 1).join(', ')}.`, fix: 'An empty screen is a rest only once per page. Elsewhere it means the section is underbuilt: set its content larger, give it the full information, put it on a colour field, or cut it.' });
|
|
232
|
+
report.viewports.push({ width: w, height: h, load: how, screens: frames.length, filled: frameChecks.map((f) => f?.filled ?? null), motion, metrics: m, layout: lay, app: appM, consoleErrors: page.consoleErrors.slice(0, 5) });
|
|
233
|
+
report.findings.push(...(appMode ? findings.filter((f) => !APP_SKIP.has(f.id)) : findings));
|
|
234
|
+
report.images.push({ viewport: label, width: w, fold, pages: sheets, film: filmSheets });
|
|
235
|
+
await page.close();
|
|
236
|
+
}
|
|
237
|
+
// Under prefers-reduced-motion the page is the finished page: nothing may stay hidden waiting for an
|
|
238
|
+
// animation that will not run. Two in three studied award pages had no reduced-motion rules at all.
|
|
239
|
+
if (!appMode && widths.some((x) => x >= 1100) && !allow.includes('reduced-hidden')) {
|
|
240
|
+
const rp = await browser.newPage({ width: 1440, height: 900, dsf: 1, reducedMotion: true });
|
|
241
|
+
try {
|
|
242
|
+
await rp.goto(url, { timeoutMs: 30000, settleMs: 1500 });
|
|
243
|
+
const hidden = await rp.eval(`(() => {
|
|
244
|
+
const out = []; const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); const rg = document.createRange();
|
|
245
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
246
|
+
const t = n.nodeValue.trim(); const el = n.parentElement; if (t.length < 3 || !el || el.closest('script, style, noscript, [aria-hidden="true"]')) continue;
|
|
247
|
+
rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.top > innerHeight || r.bottom < 0) continue;
|
|
248
|
+
let o = 1, gone = false; for (let p = el; p && p !== document.documentElement; p = p.parentElement) { const cs = getComputedStyle(p); if (cs.visibility === 'hidden') { gone = true; break; } o *= +cs.opacity; }
|
|
249
|
+
if (gone || o < 0.1) out.push(t.slice(0, 40));
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
})()`).catch(() => []);
|
|
253
|
+
if (hidden.length >= 2) report.findings.push({ level: 'FAIL', id: 'reduced-hidden', width: 1440, message: `With reduced motion requested, ${hidden.length} text block(s) on the first screen stay invisible: ` + hidden.slice(0, 4).map((t) => `"${t}"`).join(', '), fix: 'Put every hidden starting state inside @media (prefers-reduced-motion: no-preference); the reduced page is the finished page (references/motion.md §8).' });
|
|
254
|
+
} finally { await rp.close(); }
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
await browser.close();
|
|
258
|
+
if (server) server.close();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// de-duplicate findings that repeat across viewports (keep the widest occurrence, note the others)
|
|
262
|
+
const merged = [];
|
|
263
|
+
for (const f of report.findings) {
|
|
264
|
+
const same = merged.find((x) => x.id === f.id && x.level === f.level);
|
|
265
|
+
if (same && f.id !== 'headline-lines' && f.id !== 'overflow') { same.alsoAt = [...(same.alsoAt || []), f.width]; continue; }
|
|
266
|
+
merged.push(f);
|
|
267
|
+
}
|
|
268
|
+
report.findings = merged;
|
|
269
|
+
writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 1));
|
|
270
|
+
|
|
271
|
+
if (asJson) { console.log(JSON.stringify(report, null, 1)); process.exit(merged.some((f) => f.level === 'FAIL') ? 1 : 0); }
|
|
272
|
+
|
|
273
|
+
const d = report.viewports[0].metrics;
|
|
274
|
+
const line = (s = '') => console.log(s);
|
|
275
|
+
line(`TASTESKILL · INSPECT ${report.url}`);
|
|
276
|
+
line('');
|
|
277
|
+
line('MEASURED (first viewport listed)');
|
|
278
|
+
if (d.hero) line(` hero headline "${d.hero.text.slice(0, 70)}" ${d.hero.words} words · ${d.hero.lines} line(s) · ${d.hero.size}px (${d.hero.vw}vw) · ${d.hero.font} ${d.hero.weight} · tracking ${d.hero.tracking}em · leading ${d.hero.leading}`);
|
|
279
|
+
for (const v of report.viewports.slice(1)) if (v.metrics.hero) line(` @${v.width}px ${v.metrics.hero.lines} line(s) · ${v.metrics.hero.size}px`);
|
|
280
|
+
line(` typefaces ${d.type.families.filter((f) => f.share >= 0.02).map((f) => `${f.family} ${Math.round(f.share * 100)}%`).join(', ')}`);
|
|
281
|
+
line(` sizes in use ${d.type.sizes.join(' / ')} px${d.type.body ? ` body ${d.type.body.size}px / ${d.type.body.leading}` : ''}`);
|
|
282
|
+
line(` colour page ${d.color.bodyBg}${d.color.sectionBgs.length ? ' · sections ' + d.color.sectionBgs.map((s) => s.color).slice(0, 6).join(' ') : ''}${d.color.button ? ' · button ' + d.color.button.bg : ''}`);
|
|
283
|
+
line(` page ${d.page.screens} screens · ${d.page.words} words · ${d.page.img} images · hero media ${d.heroMedia[0] ? Math.round(d.heroMedia[0].ratio * 100) + '% ' + d.heroMedia[0].type : 'none'}`);
|
|
284
|
+
line(` headings ${d.headingSummary.count} · longest ${d.headingSummary.maxLines} line(s) · median ${d.headingSummary.medianWords ?? '-'} words`);
|
|
285
|
+
line('');
|
|
286
|
+
const fails = merged.filter((f) => f.level === 'FAIL'), warns = merged.filter((f) => f.level === 'WARN');
|
|
287
|
+
if (!fails.length && !warns.length) line('FINDINGS none. Measurements are clean — now judge the pictures.');
|
|
288
|
+
for (const [title, list] of [['FAIL — fix every one of these', fails], ['WARN — fix, or be able to defend', warns]]) {
|
|
289
|
+
if (!list.length) continue;
|
|
290
|
+
line(title);
|
|
291
|
+
for (const f of list) {
|
|
292
|
+
line(` [${f.id}] @${f.width}px${f.alsoAt ? ' (+' + f.alsoAt.join(', ') + ')' : ''} ${f.message}`);
|
|
293
|
+
line(` → ${f.fix}`);
|
|
294
|
+
}
|
|
295
|
+
line('');
|
|
296
|
+
}
|
|
297
|
+
line('LOOK AT THE RENDER — open these with your image viewer / Read tool before saying anything about quality:');
|
|
298
|
+
const rel = (p) => relative(process.cwd(), p).split('\\').join('/');
|
|
299
|
+
for (const im of report.images) { line(` ${(im.viewport + ' ' + im.width + 'px').padEnd(15)} ${rel(im.fold)}`); for (const p of im.pages) line(` ${''.padEnd(15)} ${rel(p)}`); for (const p of im.film || []) line(` ${'scroll film'.padEnd(15)} ${rel(p)} (6 frames a row, a third of a screen apart)`); }
|
|
300
|
+
line('');
|
|
301
|
+
line(`report: ${rel(join(outDir, 'report.json'))}`);
|
|
302
|
+
line(fails.length ? `RESULT: ${fails.length} FAIL, ${warns.length} WARN — not done.` : `RESULT: 0 FAIL, ${warns.length} WARN — measurements pass. Passing the meter is the floor, not the grade: score the screenshots with references/critique.md.`);
|
|
303
|
+
process.exit(fails.length ? 1 : 0);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// tasteskill / draw --app — deal a style for an app screen (dashboard, admin, settings, inbox).
|
|
2
|
+
//
|
|
3
|
+
// node draw.mjs --app --kind <kind> --brief "one line" [--screens overview,list,detail,settings]
|
|
4
|
+
// [--theme light|dark] [--keep palette,type] [--options 3] [--style <id>] [--seed N]
|
|
5
|
+
// node draw.mjs --show reprint the saved sheet
|
|
6
|
+
// node draw.mjs --redraw style|shell|screen:<name>
|
|
7
|
+
// node draw.mjs --app --list kinds|styles|screens
|
|
8
|
+
//
|
|
9
|
+
// Same idea as the page draw: the script deals, you choose among what it dealt, so two products do not
|
|
10
|
+
// end up in the same default look. The library (library/app.json) was distilled from a study of real
|
|
11
|
+
// product screens; it holds styles (tokens and the one move that makes each recognisable), shells and
|
|
12
|
+
// screen patterns.
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { randomInt } from 'node:crypto';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const LIB = JSON.parse(readFileSync(join(HERE, '..', '..', 'library', 'app.json'), 'utf8'));
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
const opt = (name, def) => { const i = argv.indexOf('--' + name); if (i === -1) return def; const v = argv[i + 1]; return v === undefined || v.startsWith('--') ? true : v; };
|
|
23
|
+
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : []);
|
|
24
|
+
const outDir = resolve(String(opt('out', '.taste-skill')));
|
|
25
|
+
const sheetPath = join(outDir, 'direction.json');
|
|
26
|
+
|
|
27
|
+
const KINDS = Object.keys(LIB.kinds);
|
|
28
|
+
// signature patterns are elements of one kind of product (a power flow, a sell-out strip), not whole screens
|
|
29
|
+
const SCREENS = [...new Set(LIB.patterns.filter((p) => p.screen).map((p) => p.screen))];
|
|
30
|
+
const SIGNATURES = LIB.patterns.filter((p) => p.signature);
|
|
31
|
+
const signaturesFor = (brief = '') => { const b = brief.toLowerCase(); return SIGNATURES.filter((p) => (p.cues || []).some((c) => new RegExp('(^|[^a-z])' + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(b))); };
|
|
32
|
+
|
|
33
|
+
if (opt('list')) {
|
|
34
|
+
const what = opt('list');
|
|
35
|
+
if (what === 'kinds') for (const [k, v] of Object.entries(LIB.kinds)) console.log(`${k.padEnd(14)} ${v.note}`);
|
|
36
|
+
else if (what === 'screens') for (const s of SCREENS) console.log(`${s.padEnd(12)} ${LIB.patterns.filter((p) => p.screen === s).map((p) => p.id).join(', ')}`);
|
|
37
|
+
else if (what === 'signatures') for (const p of SIGNATURES) console.log(`${p.id.padEnd(16)} ${p.name} — ${(p.cues || []).slice(0, 5).join(', ')}`);
|
|
38
|
+
else for (const s of LIB.styles) console.log(`${s.id.padEnd(22)} ${s.name} — ${s.theme}, ${s.suits.join(', ')}`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let seed = opt('seed') !== undefined && opt('seed') !== true ? Number(opt('seed')) : randomInt(1, 2 ** 31 - 1);
|
|
43
|
+
const startSeed = seed;
|
|
44
|
+
const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
45
|
+
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
|
46
|
+
const userHist = process.env.TASTE_SKILL_HISTORY || join(homedir(), '.taste-skill', 'history.json');
|
|
47
|
+
const readHist = () => { try { return JSON.parse(readFileSync(userHist, 'utf8')); } catch { return []; } };
|
|
48
|
+
const writeHist = (e) => { try { mkdirSync(dirname(userHist), { recursive: true }); const h = readHist(); h.unshift(e); writeFileSync(userHist, JSON.stringify(h.slice(0, 24), null, 1)); } catch {} };
|
|
49
|
+
const recentStyles = readHist().filter((h) => h.kind === 'app').slice(0, 3).map((h) => h.style);
|
|
50
|
+
|
|
51
|
+
const saved = existsSync(sheetPath) ? JSON.parse(readFileSync(sheetPath, 'utf8')) : null;
|
|
52
|
+
if (opt('show')) { if (!saved || saved.kind !== 'app') { console.error('No saved app sheet. Run draw.mjs --app first.'); process.exit(2); } console.log(render(saved)); process.exit(0); }
|
|
53
|
+
|
|
54
|
+
function stylePool(input) {
|
|
55
|
+
const pref = LIB.kinds[input.kind]?.styles || [];
|
|
56
|
+
let pool = LIB.styles.filter((s) => !(input.avoid || []).includes(s.id));
|
|
57
|
+
if (input.theme) { const t = pool.filter((s) => s.theme === input.theme); if (t.length) pool = t; }
|
|
58
|
+
const fit = pool.filter((s) => pref.includes(s.id) || s.suits.includes(input.kind));
|
|
59
|
+
if (fit.length >= 2) pool = fit;
|
|
60
|
+
return pool;
|
|
61
|
+
}
|
|
62
|
+
function dealStyle(input, exclude = []) {
|
|
63
|
+
let pool = stylePool(input).filter((s) => !exclude.includes(s.id));
|
|
64
|
+
if (!pool.length) pool = LIB.styles.filter((s) => !exclude.includes(s.id));
|
|
65
|
+
const fresh = pool.filter((s) => !recentStyles.includes(s.id));
|
|
66
|
+
return pick(fresh.length ? fresh : pool.length ? pool : LIB.styles);
|
|
67
|
+
}
|
|
68
|
+
// The style's own shell usually carries part of its move, so it is dealt about two times in three.
|
|
69
|
+
function dealShell(style, input, exclude = []) {
|
|
70
|
+
const own = LIB.shells.find((s) => s.id === style.shell && !exclude.includes(s.id));
|
|
71
|
+
const alts = LIB.shells.filter((s) => (style.shellAlternatives || []).includes(s.id) && !exclude.includes(s.id));
|
|
72
|
+
if (own && (!alts.length || rnd() < 0.66)) return own;
|
|
73
|
+
if (alts.length) return pick(alts);
|
|
74
|
+
const rest = LIB.shells.filter((s) => !exclude.includes(s.id));
|
|
75
|
+
return pick(rest.length ? rest : LIB.shells);
|
|
76
|
+
}
|
|
77
|
+
function dealScreen(name, style, input, exclude = []) {
|
|
78
|
+
let pool = LIB.patterns.filter((p) => p.screen === name && !exclude.includes(p.id));
|
|
79
|
+
// a pattern written for another kind of product brings that product's furniture with it (a getting-started strip on a
|
|
80
|
+
// leave planner): when none fits this kind, the sheet says to compose the screen from the chapter instead
|
|
81
|
+
pool = pool.filter((p) => !p.kinds || p.kinds.includes(input.kind));
|
|
82
|
+
return pool.length ? pick(pool) : null;
|
|
83
|
+
}
|
|
84
|
+
function deal(input) {
|
|
85
|
+
const style = input.style ? LIB.styles.find((s) => s.id === input.style) : dealStyle(input);
|
|
86
|
+
if (!style) { console.error(`No app style ${input.style}. List them with --app --list styles.`); process.exit(2); }
|
|
87
|
+
const s = { version: 1, kind: 'app', at: new Date().toISOString(), seed: startSeed, input, style, shell: dealShell(style, input), screens: [] };
|
|
88
|
+
for (const name of input.screens) s.screens.push({ name, pattern: dealScreen(name, style, input) });
|
|
89
|
+
return s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let sheet;
|
|
93
|
+
if (opt('redraw') && saved?.kind === 'app') {
|
|
94
|
+
sheet = saved; sheet.redraws = (sheet.redraws || 0) + 1;
|
|
95
|
+
const what = String(opt('redraw'));
|
|
96
|
+
if (what === 'style') { sheet.style = dealStyle(sheet.input, [sheet.style.id]); }
|
|
97
|
+
else if (what === 'shell') sheet.shell = dealShell(sheet.style, sheet.input, [sheet.shell.id]);
|
|
98
|
+
else if (what.startsWith('screen:')) { const n = what.slice(7); const i = sheet.screens.findIndex((x) => x.name === n); if (i === -1) { console.error('No screen ' + n + ' on this sheet.'); process.exit(2); } sheet.screens[i].pattern = dealScreen(n, sheet.style, sheet.input, [sheet.screens[i].pattern?.id]) || sheet.screens[i].pattern; }
|
|
99
|
+
else { console.error('Redraw style, shell or screen:<name>.'); process.exit(2); }
|
|
100
|
+
} else {
|
|
101
|
+
const kind = String(opt('kind', ''));
|
|
102
|
+
if (!KINDS.includes(kind)) { console.error(`--kind is required with --app. One of:\n ${KINDS.join(', ')}`); process.exit(2); }
|
|
103
|
+
const screens = list(opt('screens')).length ? list(opt('screens')) : (LIB.kinds[kind].screens || ['overview']);
|
|
104
|
+
const bad = screens.filter((x) => !SCREENS.includes(x));
|
|
105
|
+
if (bad.length) { console.error(`Unknown screen(s): ${bad.join(', ')}. One of: ${SCREENS.join(', ')}`); process.exit(2); }
|
|
106
|
+
const input = { kind, brief: typeof opt('brief') === 'string' ? opt('brief') : '', theme: ['light', 'dark'].includes(opt('theme')) ? opt('theme') : null, screens, keep: list(opt('keep')), style: typeof opt('style') === 'string' ? opt('style') : null };
|
|
107
|
+
if (opt('options')) {
|
|
108
|
+
const n = Math.max(2, Math.min(5, Number(opt('options')) || 3)); const pool = stylePool(input); const out = [];
|
|
109
|
+
let rest = pool.filter((s) => !recentStyles.includes(s.id)); if (rest.length < n) rest = pool;
|
|
110
|
+
while (out.length < n && rest.length) { const s = pick(rest); out.push(s); rest = rest.filter((x) => x !== s); }
|
|
111
|
+
console.log(`# ${out.length} app styles dealt for this brief\n`);
|
|
112
|
+
console.log('Choose the one that suits how this product is used (long sessions or a quick look, money or media, a team or one person), not the most familiar. Then deal it with the same command plus `--style <id>` (and without --options).\n');
|
|
113
|
+
for (const s of out) console.log(`## \`${s.id}\` — ${s.name} (${s.theme})\n${s.idea}\nThe move: ${s.signature}\n`);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
sheet = deal(input);
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(outDir, { recursive: true });
|
|
119
|
+
writeFileSync(sheetPath, JSON.stringify(sheet, null, 1));
|
|
120
|
+
if (!opt('redraw')) writeHist({ at: sheet.at, kind: 'app', style: sheet.style.id, shell: sheet.shell.id });
|
|
121
|
+
console.log(render(sheet));
|
|
122
|
+
process.exit(0);
|
|
123
|
+
|
|
124
|
+
function kebab(k) { return k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()); }
|
|
125
|
+
// A Google Fonts link for the style's faces (all of them are free there).
|
|
126
|
+
function fontLink(f) {
|
|
127
|
+
const fam = (name, w) => 'family=' + name.trim().replace(/ /g, '+') + ':wght@' + w.join(';');
|
|
128
|
+
const fams = [fam(f.family, f.weights || [400, 500, 600])];
|
|
129
|
+
const second = f.second && f.second.split(',')[0].trim();
|
|
130
|
+
if (second && !/^(system|inter\b)/i.test(second)) fams.push(fam(second, [400, 500]));
|
|
131
|
+
return `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n<link href="https://fonts.googleapis.com/css2?${fams.join('&')}&display=swap" rel="stylesheet">`;
|
|
132
|
+
}
|
|
133
|
+
function render(s) {
|
|
134
|
+
const st = s.style, t = st.tokens, o = []; const p = (x = '') => o.push(x);
|
|
135
|
+
p(`# APP SHEET — ${st.name}`);
|
|
136
|
+
p(`seed ${s.seed} · drawn ${s.at.slice(0, 10)}${s.redraws ? ` · redraws: ${s.redraws}` : ''} · kind ${s.input.kind} · ${st.theme}`);
|
|
137
|
+
if (s.input.brief) p(`Brief: ${s.input.brief}`);
|
|
138
|
+
p();
|
|
139
|
+
p('This is the style for this product. Adopt it whole and keep its move intact; everything the sheet does not fix comes from the');
|
|
140
|
+
p('shared defaults in references/app-screens.md. Redraw only for a reason you can state (it contradicts the brand, or the product is');
|
|
141
|
+
p('used in a way the style does not suit) and say why in your report. The values were measured on real product screens: use them');
|
|
142
|
+
p('as given rather than rounding them towards your habits.');
|
|
143
|
+
p();
|
|
144
|
+
p('## 1. The style'); p(st.idea); p(); p(`**The move:** ${st.signature}`); p();
|
|
145
|
+
if (st.colourBudget) p(`**Colour budget:** ${st.colourBudget}.`);
|
|
146
|
+
if (st.avoid?.length) p('**Off-style here:** ' + st.avoid.join('; ') + '.');
|
|
147
|
+
if (st.basis) p(`Distilled from ${st.basis}.${st.notes ? ' ' + st.notes : ''}`);
|
|
148
|
+
p();
|
|
149
|
+
// the style makes a screen correct; its face makes it this product's (references/app-screens.md, §2.3)
|
|
150
|
+
if (st.identity?.length) {
|
|
151
|
+
p('**Its face.** Choose one of these (or one of your own of the same weight) before you build, write it in your notes next to');
|
|
152
|
+
p('the screen\'s question, and check that it shows on the first screen. Without one the screen reads as a well-applied kit.');
|
|
153
|
+
for (const x of st.identity) p('- ' + x);
|
|
154
|
+
p();
|
|
155
|
+
}
|
|
156
|
+
const sigs = signaturesFor(s.input.brief);
|
|
157
|
+
if (sigs.length) {
|
|
158
|
+
p('**Signature elements this brief names** (an element only this kind of product has; build one of them as part of a screen):');
|
|
159
|
+
for (const g of sigs) p(`- **${g.name}** (\`${g.id}\`). ${g.blueprint}${g.phone ? ' On a phone: ' + g.phone : ''}`);
|
|
160
|
+
p();
|
|
161
|
+
}
|
|
162
|
+
const f = t.font || {};
|
|
163
|
+
if (!s.input.keep.includes('palette')) {
|
|
164
|
+
p('## 2. Tokens'); p('```css'); p(':root {');
|
|
165
|
+
for (const [k, v] of Object.entries(t)) {
|
|
166
|
+
if (k === 'font' || k === 'density') continue;
|
|
167
|
+
if (typeof v === 'string') p(` --${kebab(k)}: ${v};`);
|
|
168
|
+
else if (typeof v === 'number') p(` --${kebab(k)}: ${v}px;`);
|
|
169
|
+
else if (k === 'chart') v.forEach((c, i) => p(` --chart-${i + 1}: ${c};`));
|
|
170
|
+
else if (k === 'pills') for (const [n, [bg, ink]] of Object.entries(v)) p(` --pill-${n}-bg: ${bg}; --pill-${n}-ink: ${ink};`);
|
|
171
|
+
else if (k === 'radius') for (const [n, r] of Object.entries(v)) p(` --radius-${n}: ${r}px;`);
|
|
172
|
+
}
|
|
173
|
+
if (f.sizes) for (const [n, v] of Object.entries(f.sizes)) p(` --fs-${n}: ${v}px;`);
|
|
174
|
+
p('}'); p('```');
|
|
175
|
+
p(`Density: ${t.density}. One gap value, horizontally and vertically. ${t.shadow ? 'The shadow belongs to this dark style\'s cards only; nothing else casts one.' : 'No shadow on anything at rest.'}`);
|
|
176
|
+
p();
|
|
177
|
+
} else { p('## 2. Tokens'); p('Kept from the brand: map its colours onto this style\'s roles (ground, surface, raised, border, rule, control, text, muted, accent, status pills, chart) and keep its proportions: ' + (st.colourBudget || 'colour on data and status only') + '. Take the sizes, radii, row and control heights from the style as they are.'); p(); }
|
|
178
|
+
if (!s.input.keep.includes('type') && f.family) {
|
|
179
|
+
p(`## 3. Type — ${f.family}`);
|
|
180
|
+
p('```html'); p(fontLink(f)); p('```');
|
|
181
|
+
p(`\`font-family: "${f.family}", ${f.fallback || 'system-ui, sans-serif'}\`. Weights ${(f.weights || []).join(', ')}. Sizes: ${Object.entries(f.sizes || {}).map(([k, v]) => `${k} ${v}px`).join(' · ')}.${f.note ? ' ' + f.note : ''}${f.second ? ' Second face: ' + f.second + '.' : ''}`);
|
|
182
|
+
p('Numbers: `font-variant-numeric: tabular-nums` on every table, KPI and anything that updates. Sentence case; no tracked capitals.'); p();
|
|
183
|
+
}
|
|
184
|
+
p(`## 4. Shell — ${s.shell.name}`); p(s.shell.blueprint);
|
|
185
|
+
if (s.shell.numbers) p('Numbers: ' + Object.entries(s.shell.numbers).map(([k, v]) => `${k} ${v}`).join('; ') + '.');
|
|
186
|
+
if (s.shell.phone) p(`On a phone: ${s.shell.phone}`); p();
|
|
187
|
+
p('## 5. Screens');
|
|
188
|
+
for (const sc of s.screens) {
|
|
189
|
+
p();
|
|
190
|
+
if (!sc.pattern) { p(`### ${sc.name}`); p('No pattern in the library for this screen. Compose it from the rules in references/app-screens.md.'); continue; }
|
|
191
|
+
p(`### ${sc.name} — ${sc.pattern.name}`); p(sc.pattern.blueprint); if (sc.pattern.phone) p(`On a phone: ${sc.pattern.phone}`);
|
|
192
|
+
}
|
|
193
|
+
p();
|
|
194
|
+
p('## 6. Always');
|
|
195
|
+
p('Real data for this product, written before the markup: names, amounts that add up, dates in order, units on every figure,');
|
|
196
|
+
p('honest zeros and dashes. One chart answers one question. No gradients, glows, glass, icons in tinted squares, sparklines on KPIs,');
|
|
197
|
+
p('rainbow charts, emoji, lorem or Acme. `<skill>/assets/app.css` and `<skill>/assets/charts.js` are a neutral start set to these');
|
|
198
|
+
p('rules. Check with `node <skill>/scripts/lint.mjs <files>` and `node <skill>/scripts/inspect.mjs <page>`: this sheet switches both');
|
|
199
|
+
p('to app rules.');
|
|
200
|
+
p();
|
|
201
|
+
p(`Saved to ${sheetPath.split('\\').join('/')} · reprint with \`node draw.mjs --show\``);
|
|
202
|
+
return o.join('\n');
|
|
203
|
+
}
|