samey-cli 0.0.0-stage → 0.1.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +224 -2
  4. package/dist/ai/local-model.d.ts +26 -0
  5. package/dist/ai/local-model.js +91 -0
  6. package/dist/audit.d.ts +18 -0
  7. package/dist/audit.js +250 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +166 -0
  10. package/dist/config.d.ts +34 -0
  11. package/dist/config.js +40 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.js +5 -0
  14. package/dist/render/crawl.d.ts +34 -0
  15. package/dist/render/crawl.js +337 -0
  16. package/dist/render/extract.d.ts +8 -0
  17. package/dist/render/extract.js +441 -0
  18. package/dist/render/interact.d.ts +8 -0
  19. package/dist/render/interact.js +76 -0
  20. package/dist/render/network.d.ts +33 -0
  21. package/dist/render/network.js +92 -0
  22. package/dist/render/robots.d.ts +8 -0
  23. package/dist/render/robots.js +61 -0
  24. package/dist/render/server.d.ts +15 -0
  25. package/dist/render/server.js +174 -0
  26. package/dist/report/html.d.ts +2 -0
  27. package/dist/report/html.js +368 -0
  28. package/dist/report/prompt.d.ts +12 -0
  29. package/dist/report/prompt.js +62 -0
  30. package/dist/report/terminal.d.ts +6 -0
  31. package/dist/report/terminal.js +73 -0
  32. package/dist/rules/content.d.ts +8 -0
  33. package/dist/rules/content.js +227 -0
  34. package/dist/rules/functional.d.ts +17 -0
  35. package/dist/rules/functional.js +467 -0
  36. package/dist/rules/helpers.d.ts +11 -0
  37. package/dist/rules/helpers.js +29 -0
  38. package/dist/rules/index.d.ts +13 -0
  39. package/dist/rules/index.js +100 -0
  40. package/dist/rules/normalize.d.ts +18 -0
  41. package/dist/rules/normalize.js +114 -0
  42. package/dist/rules/static.d.ts +9 -0
  43. package/dist/rules/static.js +314 -0
  44. package/dist/rules/types.d.ts +39 -0
  45. package/dist/rules/types.js +16 -0
  46. package/dist/rules/visual.d.ts +15 -0
  47. package/dist/rules/visual.js +463 -0
  48. package/dist/score.d.ts +31 -0
  49. package/dist/score.js +66 -0
  50. package/dist/security/fetch.d.ts +26 -0
  51. package/dist/security/fetch.js +86 -0
  52. package/dist/security/net-policy.d.ts +46 -0
  53. package/dist/security/net-policy.js +178 -0
  54. package/dist/static/scan.d.ts +15 -0
  55. package/dist/static/scan.js +191 -0
  56. package/dist/types.d.ts +316 -0
  57. package/dist/types.js +1 -0
  58. package/dist/util/color.d.ts +25 -0
  59. package/dist/util/color.js +180 -0
  60. package/dist/util/text.d.ts +22 -0
  61. package/dist/util/text.js +106 -0
  62. package/package.json +69 -4
@@ -0,0 +1,61 @@
1
+ export function parseRobots(txt, agent = 'samey') {
2
+ const groups = [];
3
+ let current = null;
4
+ let lastWasAgent = false;
5
+ for (const raw of txt.split(/\r?\n/)) {
6
+ const line = raw.replace(/#.*/, '').trim();
7
+ if (!line)
8
+ continue;
9
+ const idx = line.indexOf(':');
10
+ if (idx < 0)
11
+ continue;
12
+ const key = line.slice(0, idx).trim().toLowerCase();
13
+ const value = line.slice(idx + 1).trim();
14
+ if (key === 'user-agent') {
15
+ if (!current || !lastWasAgent) {
16
+ current = { agents: [], allow: [], disallow: [] };
17
+ groups.push(current);
18
+ }
19
+ current.agents.push(value.toLowerCase());
20
+ lastWasAgent = true;
21
+ continue;
22
+ }
23
+ lastWasAgent = false;
24
+ if (!current)
25
+ continue;
26
+ if (key === 'disallow' && value)
27
+ current.disallow.push(value);
28
+ if (key === 'allow' && value)
29
+ current.allow.push(value);
30
+ }
31
+ const own = groups.find((g) => g.agents.includes(agent.toLowerCase()));
32
+ const star = groups.find((g) => g.agents.includes('*'));
33
+ const g = own ?? star;
34
+ return { allow: g?.allow ?? [], disallow: g?.disallow ?? [] };
35
+ }
36
+ function matches(rule, path) {
37
+ const anchored = rule.endsWith('$');
38
+ const body = anchored ? rule.slice(0, -1) : rule;
39
+ const re = new RegExp('^' + body.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + (anchored ? '$' : ''));
40
+ return re.test(path) ? body.length : -1;
41
+ }
42
+ /** Longest matching rule wins; Allow wins ties, per RFC 9309. */
43
+ export function isAllowed(rules, path) {
44
+ let best = -1;
45
+ let allowed = true;
46
+ for (const r of rules.disallow) {
47
+ const len = matches(r, path);
48
+ if (len > best) {
49
+ best = len;
50
+ allowed = false;
51
+ }
52
+ }
53
+ for (const r of rules.allow) {
54
+ const len = matches(r, path);
55
+ if (len >= best && len >= 0) {
56
+ best = len;
57
+ allowed = true;
58
+ }
59
+ }
60
+ return allowed;
61
+ }
@@ -0,0 +1,15 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export interface RunningServer {
3
+ url: string;
4
+ close(): Promise<void>;
5
+ }
6
+ /** Resolves a request path to a file under root, supporting clean URLs. Never escapes root. */
7
+ export declare function resolveStaticPath(root: string, urlPath: string): string | null;
8
+ /** Serves a directory over HTTP on 127.0.0.1. Read-only, no directory listings. */
9
+ export declare function serveStatic(root: string, port?: number): Promise<RunningServer>;
10
+ export declare function waitForPort(port: number, timeoutMs: number, child?: ChildProcess): Promise<string>;
11
+ /**
12
+ * Starts a user-supplied dev server command. Only ever called when the user passed
13
+ * `--serve`, which is the explicit authorization to execute project code.
14
+ */
15
+ export declare function startDevServer(command: string, cwd: string, port: number, timeoutMs: number, onLog?: (line: string) => void): Promise<RunningServer>;
@@ -0,0 +1,174 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createReadStream, existsSync, statSync } from 'node:fs';
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ const MIME = {
7
+ '.html': 'text/html; charset=utf-8',
8
+ '.htm': 'text/html; charset=utf-8',
9
+ '.css': 'text/css; charset=utf-8',
10
+ '.js': 'text/javascript; charset=utf-8',
11
+ '.mjs': 'text/javascript; charset=utf-8',
12
+ '.json': 'application/json',
13
+ '.svg': 'image/svg+xml',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ '.avif': 'image/avif',
20
+ '.ico': 'image/x-icon',
21
+ '.woff': 'font/woff',
22
+ '.woff2': 'font/woff2',
23
+ '.ttf': 'font/ttf',
24
+ '.txt': 'text/plain; charset=utf-8',
25
+ '.xml': 'application/xml',
26
+ '.webmanifest': 'application/manifest+json',
27
+ '.mp4': 'video/mp4',
28
+ '.webm': 'video/webm',
29
+ };
30
+ /** Resolves a request path to a file under root, supporting clean URLs. Never escapes root. */
31
+ export function resolveStaticPath(root, urlPath) {
32
+ let decoded;
33
+ try {
34
+ decoded = decodeURIComponent(urlPath.split('?')[0].split('#')[0]);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ if (decoded.includes('\0'))
40
+ return null;
41
+ const base = path.resolve(root);
42
+ const target = path.resolve(base, '.' + path.posix.normalize('/' + decoded));
43
+ if (target !== base && !target.startsWith(base + path.sep))
44
+ return null;
45
+ const candidates = [target, target + '.html', path.join(target, 'index.html')];
46
+ for (const c of candidates) {
47
+ if (existsSync(c) && statSync(c).isFile())
48
+ return c;
49
+ }
50
+ return null;
51
+ }
52
+ /** Serves a directory over HTTP on 127.0.0.1. Read-only, no directory listings. */
53
+ export function serveStatic(root, port = 0) {
54
+ const server = http.createServer((req, res) => {
55
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
56
+ res.writeHead(405).end();
57
+ return;
58
+ }
59
+ const file = resolveStaticPath(root, req.url ?? '/');
60
+ if (!file) {
61
+ const notFound = resolveStaticPath(root, '/404.html');
62
+ res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' });
63
+ if (notFound && req.method === 'GET')
64
+ createReadStream(notFound).pipe(res);
65
+ else
66
+ res.end('Not found');
67
+ return;
68
+ }
69
+ res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream' });
70
+ if (req.method === 'HEAD')
71
+ res.end();
72
+ else
73
+ createReadStream(file).pipe(res);
74
+ });
75
+ return new Promise((resolve, reject) => {
76
+ server.once('error', reject);
77
+ server.listen(port, '127.0.0.1', () => {
78
+ const addr = server.address();
79
+ resolve({
80
+ url: `http://127.0.0.1:${addr.port}/`,
81
+ close: () => new Promise((r) => server.close(() => r())),
82
+ });
83
+ });
84
+ });
85
+ }
86
+ function portOpen(port, host) {
87
+ return new Promise((resolve) => {
88
+ const sock = net.connect({ port, host });
89
+ sock.once('connect', () => {
90
+ sock.destroy();
91
+ resolve(true);
92
+ });
93
+ sock.once('error', () => resolve(false));
94
+ sock.setTimeout(1000, () => {
95
+ sock.destroy();
96
+ resolve(false);
97
+ });
98
+ });
99
+ }
100
+ export async function waitForPort(port, timeoutMs, child) {
101
+ const start = Date.now();
102
+ while (Date.now() - start < timeoutMs) {
103
+ if (child && child.exitCode !== null)
104
+ throw new Error(`Dev server exited early with code ${child.exitCode}`);
105
+ for (const host of ['127.0.0.1', '::1']) {
106
+ if (await portOpen(port, host))
107
+ return host === '::1' ? `http://[::1]:${port}/` : `http://127.0.0.1:${port}/`;
108
+ }
109
+ await new Promise((r) => setTimeout(r, 400));
110
+ }
111
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for a server on port ${port}`);
112
+ }
113
+ /**
114
+ * Starts a user-supplied dev server command. Only ever called when the user passed
115
+ * `--serve`, which is the explicit authorization to execute project code.
116
+ */
117
+ export async function startDevServer(command, cwd, port, timeoutMs, onLog) {
118
+ if (await portOpen(port, '127.0.0.1')) {
119
+ throw new Error(`Port ${port} is already in use. Stop the existing server or pass --url to audit it directly.`);
120
+ }
121
+ const child = spawn(command, {
122
+ cwd,
123
+ shell: true,
124
+ detached: process.platform !== 'win32',
125
+ env: { ...process.env, PORT: String(port), BROWSER: 'none', FORCE_COLOR: '0' },
126
+ stdio: ['ignore', 'pipe', 'pipe'],
127
+ });
128
+ const tail = [];
129
+ const capture = (buf) => {
130
+ for (const line of buf.toString().split('\n')) {
131
+ if (!line.trim())
132
+ continue;
133
+ tail.push(line);
134
+ if (tail.length > 20)
135
+ tail.shift();
136
+ onLog?.(line);
137
+ }
138
+ };
139
+ child.stdout?.on('data', capture);
140
+ child.stderr?.on('data', capture);
141
+ const kill = () => new Promise((resolve) => {
142
+ if (child.exitCode !== null)
143
+ return resolve();
144
+ child.once('exit', () => resolve());
145
+ try {
146
+ if (process.platform !== 'win32' && child.pid)
147
+ process.kill(-child.pid, 'SIGTERM');
148
+ else
149
+ child.kill('SIGTERM');
150
+ }
151
+ catch {
152
+ resolve();
153
+ }
154
+ setTimeout(() => {
155
+ try {
156
+ if (process.platform !== 'win32' && child.pid)
157
+ process.kill(-child.pid, 'SIGKILL');
158
+ }
159
+ catch {
160
+ /* already gone */
161
+ }
162
+ resolve();
163
+ }, 3000).unref();
164
+ });
165
+ try {
166
+ const url = await waitForPort(port, timeoutMs, child);
167
+ return { url, close: kill };
168
+ }
169
+ catch (err) {
170
+ await kill();
171
+ const detail = tail.length ? `\nLast output:\n ${tail.slice(-8).join('\n ')}` : '';
172
+ throw new Error(`${err.message}${detail}`);
173
+ }
174
+ }
@@ -0,0 +1,2 @@
1
+ import type { AuditReport } from '../types.js';
2
+ export declare function renderHtml(report: AuditReport, outDir: string): string;
@@ -0,0 +1,368 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { CATEGORY_LABELS, SCORING } from '../score.js';
5
+ import { fixPrompt, PROMPT_HEADER } from './prompt.js';
6
+ const esc = (s) => String(s ?? '')
7
+ .replace(/&/g, '&amp;')
8
+ .replace(/</g, '&lt;')
9
+ .replace(/>/g, '&gt;')
10
+ .replace(/"/g, '&quot;')
11
+ .replace(/'/g, '&#39;');
12
+ const SEV_LABEL = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Note' };
13
+ const ICON = {
14
+ copy: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2"/></svg>',
15
+ chevron: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>',
16
+ file: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 2.5h-5a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-7z"/><path d="M9.5 2.5v3h3"/></svg>',
17
+ page: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2.5" y="3" width="11" height="10" rx="1.5"/><path d="M2.5 6h11"/></svg>',
18
+ term: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 5l3 3-3 3M8 11h5"/></svg>',
19
+ net: '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="5.5"/><path d="M2.5 8h11M8 2.5c1.8 1.6 1.8 9.4 0 11M8 2.5c-1.8 1.6-1.8 9.4 0 11"/></svg>',
20
+ metric: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13V8M8 13V3M13 13v-6"/></svg>',
21
+ };
22
+ function loadShots(report, outDir) {
23
+ const byPage = new Map();
24
+ const parts = [];
25
+ report.pages.forEach((p, i) => {
26
+ const entry = {};
27
+ for (const vp of ['desktop', 'mobile']) {
28
+ const s = p.screenshots[vp];
29
+ if (!s)
30
+ continue;
31
+ const file = path.join(outDir, s.file);
32
+ if (!existsSync(file))
33
+ continue;
34
+ const id = `shot-${i}-${vp}`;
35
+ const data = readFileSync(file).toString('base64');
36
+ parts.push(`<image id="${id}" href="data:image/jpeg;base64,${data}" width="${s.width}" height="${s.height}" preserveAspectRatio="none"/>`);
37
+ entry[vp] = { id, width: s.width, height: s.height };
38
+ }
39
+ byPage.set(p.path, entry);
40
+ });
41
+ return { defs: parts.length ? `<svg class="defs" width="0" height="0" aria-hidden="true"><defs>${parts.join('')}</defs></svg>` : '', byPage };
42
+ }
43
+ function crop(e, shot) {
44
+ if (!shot || !e.rect)
45
+ return '';
46
+ const r = e.rect;
47
+ if (r.y >= shot.height || r.width < 2 || r.height < 2)
48
+ return '';
49
+ const pad = 32;
50
+ const minW = Math.min(shot.width, shot.width >= 1000 ? 640 : 390);
51
+ let w = Math.min(shot.width, Math.max(minW, r.width + pad * 2));
52
+ let x = Math.max(0, Math.min(shot.width - w, r.x + r.width / 2 - w / 2));
53
+ let h = Math.min(Math.max(r.height + pad * 2, w * 0.35), w * 0.75);
54
+ let y = Math.max(0, Math.min(shot.height - h, r.y - pad));
55
+ if (r.height + pad * 2 > h)
56
+ y = Math.max(0, r.y - pad);
57
+ [w, x, h, y] = [w, x, h, y].map(Math.round);
58
+ return `<svg class="crop" viewBox="${x} ${y} ${w} ${h}" role="img" aria-label="Screenshot crop"><use href="#${shot.id}"/><rect class="hl" x="${r.x}" y="${r.y}" width="${r.width}" height="${r.height}" rx="3"/></svg>`;
59
+ }
60
+ function evidenceRow(e, shots) {
61
+ const icon = e.type === 'source' ? ICON.file : e.type === 'console' ? ICON.term : e.type === 'network' ? ICON.net : e.type === 'metric' ? ICON.metric : ICON.page;
62
+ const where = [];
63
+ if (e.route)
64
+ where.push(`<span class="pill">${esc(e.route)}${e.viewport ? ` <span class="dim">${e.viewport}</span>` : ''}</span>`);
65
+ if (e.alsoOn?.length)
66
+ where.push(`<span class="dim">also on ${e.alsoOn.map(esc).join(', ')}</span>`);
67
+ if (e.file)
68
+ where.push(`<code class="loc">${esc(e.file)}${e.line ? `:${e.line}` : ''}</code>`);
69
+ if (e.selector)
70
+ where.push(`<code class="sel">${esc(e.selector)}</code>`);
71
+ const shot = e.route && e.viewport ? shots.get(e.route)?.[e.viewport] : undefined;
72
+ return `<li class="ev">
73
+ <span class="ev-icon">${icon}</span>
74
+ <div class="ev-body">
75
+ ${where.length ? `<div class="ev-where">${where.join('')}</div>` : ''}
76
+ ${e.excerpt ? `<div class="ev-snippet">${esc(e.excerpt)}</div>` : ''}
77
+ ${e.metric ? `<div class="ev-detail">${Object.entries(e.metric).map(([k, v]) => `${esc(k)}: ${esc(v)}`).join(' · ')}</div>` : ''}
78
+ ${e.detail ? `<div class="ev-detail">${esc(e.detail)}</div>` : ''}
79
+ ${crop(e, shot)}
80
+ </div>
81
+ </li>`;
82
+ }
83
+ function findingCard(f, shots, open, penalty) {
84
+ return `<details class="finding" data-cat="${f.category}" data-sev="${f.severity}" id="${esc(f.id)}"${open ? ' open' : ''}>
85
+ <summary>
86
+ <span class="chev">${ICON.chevron}</span>
87
+ <span class="sev sev-${f.severity}">${SEV_LABEL[f.severity]}</span>
88
+ <span class="f-title">${esc(f.title)}</span>
89
+ <span class="f-meta">${esc(CATEGORY_LABELS[f.category])}${f.routes?.length ? ` · ${f.routes.length} route${f.routes.length === 1 ? '' : 's'}` : ''}${f.advisory ? ' · advisory' : penalty ? ` · +${penalty}` : ''}</span>
90
+ </summary>
91
+ <div class="f-body">
92
+ <p class="f-msg">${esc(f.description)}</p>
93
+ <div class="f-fix"><span class="label">Fix</span><p>${esc(f.recommendation)}</p></div>
94
+ <div class="f-tags">
95
+ <span class="kind ${f.verified ? 'kind-v' : 'kind-o'}">${f.verified ? 'Verified defect' : f.advisory ? 'Model observation' : 'Design observation'}</span>
96
+ <span>Confidence: <b>${Math.round(f.confidence * 100)}%</b></span>
97
+ <span>Source: <b>${f.source === 'model' ? 'local model' : f.source}</b></span>
98
+ <span>Rule: <code>${esc(f.ruleId)}</code></span>
99
+ <label class="pick"><input type="checkbox" class="sel-box" value="${esc(f.id)}"> Add to fix prompt</label>
100
+ <button class="btn-ghost copy-one" type="button" data-id="${esc(f.id)}">${ICON.copy}<span>Copy prompt</span></button>
101
+ </div>
102
+ ${f.evidence.length ? `<ul class="evidence">${f.evidence.slice(0, 4).map((e) => evidenceRow(e, shots)).join('')}</ul>` : ''}
103
+ ${f.evidence.length > 4 ? `<details class="more"><summary>Show ${f.evidence.length - 4} more</summary><ul class="evidence">${f.evidence.slice(4).map((e) => evidenceRow(e, shots)).join('')}</ul></details>` : ''}
104
+ </div>
105
+ </details>`;
106
+ }
107
+ const CSS = `
108
+ :root{--bg:#f6f5f1;--surface:#fffefb;--ink:#1b1b19;--muted:#66655f;--faint:#9a998f;--rule:#e3e1d9;--accent:#c2410c;--hl:#e8590c;
109
+ --crit:#b42318;--high:#c4320a;--med:#a15c07;--low:#4b5563;--info:#6b7280;--bar:#1b1b19;--track:#e9e7df;--code:#f0eee7}
110
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#141413;--surface:#1c1c1a;--ink:#ecebe6;--muted:#a3a29a;--faint:#76756e;--rule:#2e2d2a;--accent:#fb923c;--hl:#fb923c;
111
+ --crit:#f97066;--high:#fb8c5a;--med:#f5b84a;--low:#a8b0bd;--info:#9aa1ad;--bar:#ecebe6;--track:#2e2d2a;--code:#242421}}
112
+ :root[data-theme="dark"]{--bg:#141413;--surface:#1c1c1a;--ink:#ecebe6;--muted:#a3a29a;--faint:#76756e;--rule:#2e2d2a;--accent:#fb923c;--hl:#fb923c;
113
+ --crit:#f97066;--high:#fb8c5a;--med:#f5b84a;--low:#a8b0bd;--info:#9aa1ad;--bar:#ecebe6;--track:#2e2d2a;--code:#242421}
114
+ *{box-sizing:border-box}
115
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;-webkit-font-smoothing:antialiased}
116
+ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
117
+ .wrap{max-width:1040px;margin:0 auto;padding:40px 24px 120px}
118
+ header.top{display:flex;justify-content:space-between;align-items:baseline;gap:16px;border-bottom:1px solid var(--rule);padding-bottom:14px;flex-wrap:wrap}
119
+ .brand{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700;letter-spacing:-.02em;font-size:17px}
120
+ .brand span{color:var(--accent)}
121
+ .target{color:var(--muted);font-size:13px;overflow-wrap:anywhere}
122
+ .hero{display:grid;grid-template-columns:minmax(220px,300px) 1fr;gap:48px;padding:40px 0 32px;border-bottom:1px solid var(--rule)}
123
+ .score{font-size:96px;line-height:.9;font-weight:700;letter-spacing:-.05em;font-variant-numeric:tabular-nums}
124
+ .score small{font-size:20px;color:var(--faint);font-weight:500;letter-spacing:0}
125
+ .eyebrow{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600;margin-bottom:8px}
126
+ .grade{margin-top:12px;font-size:14px;color:var(--muted);max-width:30ch}
127
+ .cat .w{color:var(--faint);font-size:12px;font-variant-numeric:tabular-nums}
128
+ .limits{margin:12px 0 0;padding:12px 16px 12px 32px;background:var(--surface);border:1px solid var(--rule);border-radius:8px;color:var(--muted);font-size:14px}
129
+ .limits li{margin:2px 0}
130
+ .kind{font-size:12px;font-weight:600;padding:1px 8px;border-radius:999px;border:1px solid currentColor}
131
+ .kind-v{color:var(--high)}.kind-o{color:var(--muted)}
132
+ .summary{color:var(--muted);margin-top:8px;max-width:30ch}
133
+ .cats{display:grid;gap:10px;align-content:start;grid-template-columns:auto}
134
+ .cats .cat{grid-template-columns:230px 1fr 44px}
135
+ .cat{display:grid;grid-template-columns:minmax(150px,auto) 1fr 44px;align-items:center;gap:14px;font-size:14px}
136
+ .cat .track{height:6px;background:var(--track);border-radius:3px;overflow:hidden}
137
+ .cat .fill{height:100%;background:var(--bar);border-radius:3px}
138
+ .cat .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
139
+ .cat.na{color:var(--faint)} .cat.na .num{font-weight:400}
140
+ .cat-btn{all:unset;cursor:pointer;border-radius:4px}
141
+ .cat-btn:hover{text-decoration:underline;text-underline-offset:3px}
142
+ .cat-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
143
+ h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600;margin:44px 0 14px}
144
+ .coverage{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
145
+ .coverage div{background:var(--surface);padding:12px 14px}
146
+ .coverage b{display:block;font-size:20px;font-variant-numeric:tabular-nums}
147
+ .coverage span{color:var(--muted);font-size:13px}
148
+ .note{margin-top:12px;padding:10px 14px;border-left:3px solid var(--accent);background:var(--surface);color:var(--ink);font-size:14px;border-radius:0 6px 6px 0}
149
+ .first{counter-reset:n;list-style:none;padding:0;margin:0;border-top:1px solid var(--rule)}
150
+ .first li{counter-increment:n;display:grid;grid-template-columns:28px 80px 1fr auto;gap:12px;align-items:baseline;padding:11px 0;border-bottom:1px solid var(--rule)}
151
+ .first li::before{content:counter(n);color:var(--faint);font-variant-numeric:tabular-nums}
152
+ .first a{color:inherit;text-decoration:none;font-weight:500}
153
+ .first a:hover{text-decoration:underline;text-underline-offset:3px}
154
+ .first .cat-name{color:var(--muted);font-size:13px}
155
+ .filters{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
156
+ .chip{border:1px solid var(--rule);background:var(--surface);color:var(--ink);padding:5px 11px;border-radius:999px;font:inherit;font-size:13px;cursor:pointer}
157
+ .chip[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
158
+ .chip:focus-visible,.btn-ghost:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
159
+ .finding{background:var(--surface);border:1px solid var(--rule);border-radius:8px;margin-bottom:8px}
160
+ .finding summary{list-style:none;display:grid;grid-template-columns:16px 76px 1fr auto;gap:12px;align-items:center;padding:13px 16px;cursor:pointer}
161
+ .finding summary::-webkit-details-marker{display:none}
162
+ .chev svg{width:14px;height:14px;fill:none;stroke:var(--faint);stroke-width:1.6;transition:transform .15s}
163
+ .finding[open] .chev svg{transform:rotate(90deg)}
164
+ .f-title{font-weight:600}
165
+ .f-meta{color:var(--muted);font-size:13px;white-space:nowrap}
166
+ .sev{font-size:12px;font-weight:600;letter-spacing:.02em}
167
+ .sev::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:currentColor;vertical-align:1px}
168
+ .sev-critical{color:var(--crit)}.sev-high{color:var(--high)}.sev-medium{color:var(--med)}.sev-low{color:var(--low)}.sev-info{color:var(--info)}
169
+ .f-body{padding:0 16px 16px 44px}
170
+ .f-msg{margin:0 0 12px;max-width:75ch}
171
+ .f-fix{display:grid;grid-template-columns:44px 1fr;gap:8px;padding:10px 12px;background:var(--code);border-radius:6px;max-width:80ch}
172
+ .f-fix p{margin:0}
173
+ .label{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--accent);padding-top:2px}
174
+ .f-tags{display:flex;flex-wrap:wrap;gap:6px 18px;align-items:center;margin:12px 0;color:var(--muted);font-size:13px}
175
+ .f-tags b{color:var(--ink);font-weight:600}
176
+ .pick{display:inline-flex;gap:6px;align-items:center;cursor:pointer}
177
+ .btn-ghost{display:inline-flex;gap:6px;align-items:center;border:1px solid var(--rule);background:transparent;color:var(--ink);border-radius:6px;padding:4px 10px;font:inherit;font-size:13px;cursor:pointer}
178
+ .btn-ghost:hover{border-color:var(--ink)}
179
+ .btn-ghost svg,.ev-icon svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}
180
+ .evidence{list-style:none;padding:0;margin:0;border-top:1px solid var(--rule)}
181
+ .ev{display:grid;grid-template-columns:22px 1fr;gap:8px;padding:10px 0;border-bottom:1px solid var(--rule)}
182
+ .ev:last-child{border-bottom:0}
183
+ .ev-icon{color:var(--faint);padding-top:2px}
184
+ .ev-where{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
185
+ .pill{font-size:12.5px;background:var(--code);padding:1px 7px;border-radius:4px}
186
+ .dim{color:var(--faint)}
187
+ .loc{color:var(--accent)}
188
+ .sel{color:var(--muted);overflow-wrap:anywhere}
189
+ .ev-snippet{margin-top:4px;font-size:14px;overflow-wrap:anywhere}
190
+ .ev-detail{margin-top:2px;color:var(--muted);font-size:13px;overflow-wrap:anywhere}
191
+ .crop{display:block;margin-top:8px;width:100%;max-width:560px;height:auto;border:1px solid var(--rule);border-radius:6px;background:var(--code)}
192
+ .crop .hl{fill:none;stroke:var(--hl);stroke-width:3;vector-effect:non-scaling-stroke}
193
+ .pages{display:grid;gap:28px}
194
+ .pg{display:grid;grid-template-columns:1fr 150px;gap:14px;align-items:start}
195
+ .pg-head{grid-column:1/-1;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
196
+ .pg-head b{font-weight:600}
197
+ .pg svg.full{width:100%;height:auto;max-height:520px;border:1px solid var(--rule);border-radius:6px;display:block;background:var(--code)}
198
+ .pg .frame{max-height:520px;overflow:hidden;border-radius:6px}
199
+ .err{color:var(--crit);font-size:13px}
200
+ .method{color:var(--muted);font-size:14px;max-width:75ch}
201
+ .method code{background:var(--code);padding:1px 5px;border-radius:4px}
202
+ .tray{position:fixed;left:50%;bottom:20px;transform:translateX(-50%) translateY(150%);background:var(--ink);color:var(--bg);border-radius:10px;padding:10px 12px 10px 18px;display:flex;gap:14px;align-items:center;box-shadow:0 6px 24px rgba(0,0,0,.18);transition:transform .2s;font-size:14px;max-width:calc(100% - 32px)}
203
+ .tray{visibility:hidden}.tray.show{transform:translateX(-50%) translateY(0);visibility:visible}
204
+ .finding .more summary{display:block;cursor:pointer;color:var(--muted);font-size:13px;padding:10px 0;list-style:none}
205
+ .finding .more summary:hover{color:var(--ink)}
206
+ .tray button{border:0;background:var(--bg);color:var(--ink);border-radius:6px;padding:6px 12px;font:inherit;font-size:13px;font-weight:600;cursor:pointer}
207
+ .empty{padding:24px;text-align:center;color:var(--muted);background:var(--surface);border:1px dashed var(--rule);border-radius:8px}
208
+ .defs{position:absolute;width:0;height:0;overflow:hidden}
209
+ @media (max-width:720px){.wrap{padding:24px 16px 120px}.hero{grid-template-columns:1fr;gap:28px}.score{font-size:72px}.cats .cat{grid-template-columns:140px 1fr 36px}
210
+ .finding summary{grid-template-columns:14px 1fr;row-gap:2px}.finding summary .f-title{grid-column:2}.finding summary .f-meta{grid-column:2;white-space:normal}.finding summary .sev{grid-column:2}
211
+ .f-body{padding:0 14px 14px}.first li{grid-template-columns:24px 1fr;}.first li .sev,.first li .cat-name{grid-column:2}.pg{grid-template-columns:1fr 96px}}
212
+ @media (prefers-reduced-motion:reduce){*{transition:none!important}}
213
+ `;
214
+ const JS = `
215
+ const prompts = JSON.parse(document.getElementById('prompts').textContent);
216
+ const header = JSON.parse(document.getElementById('prompt-header').textContent);
217
+ async function copy(text, btn){
218
+ try { await navigator.clipboard.writeText(text); }
219
+ catch { const t=document.createElement('textarea'); t.value=text; document.body.appendChild(t); t.select(); document.execCommand('copy'); t.remove(); }
220
+ if(btn){ const s=btn.querySelector('span')||btn; const o=s.textContent; s.textContent='Copied'; setTimeout(()=>s.textContent=o,1400); }
221
+ }
222
+ document.querySelectorAll('.copy-one').forEach(b=>b.addEventListener('click',()=>copy(header+prompts[b.dataset.id],b)));
223
+ const tray=document.getElementById('tray'), count=document.getElementById('tray-count');
224
+ function sync(){ const n=document.querySelectorAll('.sel-box:checked').length; count.textContent=n+' selected'; tray.classList.toggle('show',n>0); }
225
+ document.querySelectorAll('.sel-box').forEach(c=>c.addEventListener('change',sync));
226
+ document.getElementById('tray-copy').addEventListener('click',e=>{
227
+ const ids=[...document.querySelectorAll('.sel-box:checked')].map(c=>c.value);
228
+ copy(header+ids.map((id,i)=>(i+1)+'. '+prompts[id]).join('\\n\\n'),e.currentTarget);
229
+ });
230
+ document.getElementById('tray-clear').addEventListener('click',()=>{document.querySelectorAll('.sel-box:checked').forEach(c=>c.checked=false);sync();});
231
+ const state={cat:'all',sev:'all'};
232
+ function apply(){
233
+ let shown=0;
234
+ document.querySelectorAll('.finding').forEach(f=>{
235
+ const sevOk = state.sev==='all' || (state.sev==='important' ? ['critical','high'].includes(f.dataset.sev) : f.dataset.sev===state.sev);
236
+ const ok=(state.cat==='all'||f.dataset.cat===state.cat)&&sevOk;
237
+ f.hidden=!ok; if(ok) shown++;
238
+ });
239
+ document.getElementById('none').hidden=shown>0;
240
+ }
241
+ document.querySelectorAll('.chip').forEach(ch=>ch.addEventListener('click',()=>{
242
+ const k=ch.dataset.kind; state[k]=ch.dataset.val;
243
+ document.querySelectorAll('.chip[data-kind="'+k+'"]').forEach(o=>o.setAttribute('aria-pressed',String(o===ch)));
244
+ apply();
245
+ }));
246
+ document.querySelectorAll('.cat-btn').forEach(b=>b.addEventListener('click',()=>{
247
+ const chip=document.querySelector('.chip[data-kind="cat"][data-val="'+b.dataset.cat+'"]'); if(chip){chip.click(); document.getElementById('findings').scrollIntoView({behavior:'smooth'});}
248
+ }));
249
+ `;
250
+ export function renderHtml(report, outDir) {
251
+ const { defs, byPage } = loadShots(report, outDir);
252
+ const f = report.findings;
253
+ const scored = f.filter((x) => !x.advisory);
254
+ const bySev = (s) => scored.filter((x) => x.severity === s).length;
255
+ // Show local paths relative to home so a shared report does not reveal the account name.
256
+ const home = os.homedir();
257
+ const local = report.target.path && home && report.target.path.startsWith(home) ? '~' + report.target.path.slice(home.length) : report.target.path;
258
+ const target = local ?? report.target.url ?? '';
259
+ const prompts = Object.fromEntries(f.map((x) => [x.id, fixPrompt(x)]));
260
+ const cov = report.coverage;
261
+ const summary = report.score === null
262
+ ? 'Not enough was measured to score this site.'
263
+ : `${scored.length} finding${scored.length === 1 ? '' : 's'}: ${bySev('critical') + bySev('high')} high priority, ${bySev('medium')} medium, ${bySev('low')} low.`;
264
+ const cats = report.categories
265
+ .map((c) => {
266
+ const label = esc(CATEGORY_LABELS[c.category]);
267
+ const weight = `<span class="w">${Math.round(c.weight * 100)}%</span>`;
268
+ if (c.score === null)
269
+ return `<div class="cat na"><span>${label} ${weight}</span><div class="track"></div><span class="num">n/a</span></div>`;
270
+ return `<div class="cat"><button class="cat-btn" data-cat="${c.category}" type="button">${label} ${weight}</button><div class="track"><div class="fill" style="width:${c.score}%"></div></div><span class="num">${Math.round(c.score)}</span></div>`;
271
+ })
272
+ .join('');
273
+ const penalties = new Map(report.categories.flatMap((c) => c.deductions.map((d) => [d.findingId, d.penalty])));
274
+ // Not configuring a model is the normal case; only surface model notes when one was asked for.
275
+ const notes = [...cov.limitations];
276
+ if (cov.model.note && cov.model.name)
277
+ notes.push(`Local model: ${cov.model.note}.`);
278
+ const fixFirst = scored.slice(0, 5);
279
+ const usedCats = [...new Set(f.map((x) => x.category))];
280
+ const pages = report.pages
281
+ .map((p) => {
282
+ const s = byPage.get(p.path) ?? {};
283
+ const d = s.desktop;
284
+ const m = s.mobile;
285
+ return `<div class="pg">
286
+ <div class="pg-head"><b>${esc(p.path)}</b><span class="dim">${esc(p.title ?? '')}</span>${p.status && p.status >= 400 ? `<span class="err">HTTP ${p.status}</span>` : ''}${p.error ? `<span class="err">${esc(p.error)}</span>` : ''}</div>
287
+ ${d ? `<div class="frame"><svg class="full" viewBox="0 0 ${d.width} ${Math.min(d.height, d.width * 1.6)}" preserveAspectRatio="xMidYMin slice" role="img" aria-label="Desktop screenshot of ${esc(p.path)}"><use href="#${d.id}"/></svg></div>` : '<div></div>'}
288
+ ${m ? `<div class="frame"><svg class="full" viewBox="0 0 ${m.width} ${Math.min(m.height, m.width * 3.5)}" preserveAspectRatio="xMidYMin slice" role="img" aria-label="Mobile screenshot of ${esc(p.path)}"><use href="#${m.id}"/></svg></div>` : ''}
289
+ </div>`;
290
+ })
291
+ .join('');
292
+ return `<!doctype html>
293
+ <html lang="en">
294
+ <head>
295
+ <meta charset="utf-8">
296
+ <meta name="viewport" content="width=device-width,initial-scale=1">
297
+ <title>samey report${report.repo?.name ? ` · ${esc(report.repo.name)}` : ''}</title>
298
+ <style>${CSS}</style>
299
+ </head>
300
+ <body>
301
+ ${defs}
302
+ <div class="wrap">
303
+ <header class="top">
304
+ <div class="brand">same<span>y</span></div>
305
+ <div class="target">${esc(target)} · ${esc(new Date(report.generatedAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }))}</div>
306
+ </header>
307
+
308
+ <section class="hero">
309
+ <div>
310
+ <div class="eyebrow">Slop score</div>
311
+ <div class="score">${report.score === null ? '–' : Math.round(report.score)}<small>/100</small></div>
312
+ <div class="grade">Lower is better. 0 means no slop indicators were found.</div>
313
+ <p class="summary">${esc(summary)}</p>
314
+ </div>
315
+ <div class="cats">${cats}</div>
316
+ </section>
317
+
318
+ <h2>Coverage</h2>
319
+ <div class="coverage">
320
+ <div><b>${cov.pagesAudited}</b><span>pages rendered${cov.pagesFailed ? `, ${cov.pagesFailed} failed` : ''}</span></div>
321
+ <div><b>${report.repo ? report.repo.fileCount : '–'}</b><span>source files scanned${report.repo?.truncated ? ' (limit hit)' : ''}</span></div>
322
+ <div><b>${cov.rendered ? '2' : '0'}</b><span>viewports (1440px, 390px)</span></div>
323
+ <div><b>${cov.interactions && cov.rendered ? 'on' : 'off'}</b><span>safe click testing</span></div>
324
+ <div><b>${cov.model.enabled ? esc(cov.model.name) : 'off'}</b><span>local model review</span></div>
325
+ </div>
326
+ ${notes.length ? `<ul class="limits">${notes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>` : ''}
327
+
328
+ ${fixFirst.length ? `<h2>Fix first</h2><ol class="first">${fixFirst.map((x) => `<li><span class="sev sev-${x.severity}">${SEV_LABEL[x.severity]}</span><a href="#${esc(x.id)}" data-open="${esc(x.id)}">${esc(x.title)}</a><span class="cat-name">${esc(CATEGORY_LABELS[x.category])}</span></li>`).join('')}</ol>` : ''}
329
+
330
+ <h2 id="findings">Findings</h2>
331
+ ${f.length
332
+ ? `<div class="filters" role="toolbar" aria-label="Filter findings">
333
+ <button class="chip" data-kind="cat" data-val="all" aria-pressed="true">All categories</button>
334
+ ${usedCats.map((c) => `<button class="chip" data-kind="cat" data-val="${c}" aria-pressed="false">${esc(CATEGORY_LABELS[c])}</button>`).join('')}
335
+ </div>
336
+ <div class="filters" role="toolbar" aria-label="Filter by severity">
337
+ <button class="chip" data-kind="sev" data-val="all" aria-pressed="true">Any severity</button>
338
+ <button class="chip" data-kind="sev" data-val="important" aria-pressed="false">Critical and high</button>
339
+ <button class="chip" data-kind="sev" data-val="medium" aria-pressed="false">Medium</button>
340
+ <button class="chip" data-kind="sev" data-val="low" aria-pressed="false">Low</button>
341
+ </div>
342
+ ${f.map((x, i) => findingCard(x, byPage, i < 2, penalties.get(x.id))).join('')}`
343
+ : ''}
344
+ <div class="empty" id="none" ${f.length ? 'hidden' : ''}>No findings${f.length ? ' match these filters' : ''}.</div>
345
+
346
+ ${pages ? `<h2>Pages</h2><div class="pages">${pages}</div>` : ''}
347
+
348
+ <h2>How the score works</h2>
349
+ <div class="method">
350
+ <p>The score counts detected quality problems; it is not a probability that AI wrote the site. Each finding adds a penalty <code>p = s × c × w</code>: severity <code>s</code> (low 0.25, medium 0.5, high 0.75, critical 1), confidence <code>c</code> (0 to 1), and a per-rule weight <code>w</code> (default ${SCORING.defaultPenalty}). Penalties in a category add up to <code>P</code>, and the category score is <code>100 × (1 − e<sup>−P/${SCORING.saturation}</sup>)</code>, so many small findings cannot push it past 100. The overall score is the weighted average of the measured categories; categories that could not be measured are left out rather than counted as clean.</p>
351
+ <p>Identical evidence on several pages, such as a shared footer, counts once. Verified defects are measured; design observations are heuristics worth a human look. Local model observations are advisory and never change the score. The weights are starting points, not calibrated values.</p>
352
+ <p class="dim">samey ${esc(report.tool.version)} · scoring ${esc(report.versions.scoring)} · scan ${esc(report.scanId.slice(0, 8))} · ${(report.durationMs / 1000).toFixed(1)}s</p>
353
+ </div>
354
+ </div>
355
+
356
+ <div class="tray" id="tray" role="region" aria-label="Selected findings">
357
+ <span id="tray-count">0 selected</span>
358
+ <button id="tray-copy" type="button"><span>Copy fix prompt</span></button>
359
+ <button id="tray-clear" type="button">Clear</button>
360
+ </div>
361
+ <script type="application/json" id="prompt-header">${JSON.stringify(PROMPT_HEADER).replace(/</g, '\\u003c')}</script>
362
+ <script type="application/json" id="prompts">${JSON.stringify(prompts).replace(/</g, '\\u003c')}</script>
363
+ <script>${JS}
364
+ document.querySelectorAll('[data-open]').forEach(a=>a.addEventListener('click',()=>{const d=document.getElementById(a.dataset.open); if(d) d.open=true;}));
365
+ </script>
366
+ </body>
367
+ </html>`;
368
+ }
@@ -0,0 +1,12 @@
1
+ import type { AuditReport, Finding } from '../types.js';
2
+ /** Shared by the HTML report's copy buttons, fix-prompt.md, and `samey prompt`. */
3
+ export declare const PROMPT_HEADER = "Fix the following UI issues reported by samey. Make focused changes, keep unrelated code as is, and re-run samey afterwards to verify.\n\n";
4
+ /** One finding as plain text an AI coding agent can act on. */
5
+ export declare function fixPrompt(f: Finding): string;
6
+ /**
7
+ * The whole audit as one prompt: measured defects first, then design observations the agent
8
+ * should weigh rather than apply blindly. Info-level and advisory model findings are left out.
9
+ */
10
+ export declare function fullPrompt(report: AuditReport, opts?: {
11
+ onlyDefects?: boolean;
12
+ }): string | null;