@myrtilleio/redline 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.
package/src/format.js ADDED
@@ -0,0 +1,346 @@
1
+ // Shared between the Node side and the browser overlay: the bundler strips the
2
+ // trailing `export` line and inlines this file into the client script, so keep
3
+ // it dependency-free and browser-safe.
4
+
5
+ const VERDICTS = {
6
+ good: { icon: '✓', label: 'Good', meaning: 'keep this, and reuse the pattern elsewhere' },
7
+ fix: { icon: '✗', label: 'Fix', meaning: 'change this' },
8
+ idea: { icon: '?', label: 'Idea', meaning: 'consider this suggestion or answer the question' },
9
+ };
10
+
11
+ function verdictOf(a) {
12
+ return VERDICTS[a.verdict] || VERDICTS.idea;
13
+ }
14
+
15
+ function round(n) {
16
+ return Math.round(Number(n) || 0);
17
+ }
18
+
19
+ // Guess what a freehand mark means from its geometry, so the AI gets
20
+ // "circled" or "crossed out" rather than a list of coordinates.
21
+ function describeStroke(points) {
22
+ if (!points || points.length < 2) return 'dot';
23
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, len = 0;
24
+ for (let i = 0; i < points.length; i++) {
25
+ const [x, y] = points[i];
26
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x);
27
+ minY = Math.min(minY, y); maxY = Math.max(maxY, y);
28
+ if (i) len += Math.hypot(x - points[i - 1][0], y - points[i - 1][1]);
29
+ }
30
+ const w = maxX - minX, h = maxY - minY;
31
+ const size = Math.max(w, h);
32
+ if (size < 12) return 'dot';
33
+ const [sx, sy] = points[0];
34
+ const [ex, ey] = points[points.length - 1];
35
+ const gap = Math.hypot(ex - sx, ey - sy);
36
+ const diag = Math.hypot(w, h);
37
+ if (len > 3.2 * (w + h)) return 'scribble (crossed out)';
38
+ // Closed loop of any proportion: a circle round a wide logo row is flat but still a circle.
39
+ if (gap < 0.3 * size && len > 1.8 * diag && Math.min(w, h) >= 20) return 'circled';
40
+ if (len < 1.25 * diag) {
41
+ if (h < 0.2 * w) return 'horizontal line (underline)';
42
+ if (w < 0.2 * h) return 'vertical line';
43
+ return 'diagonal line / arrow';
44
+ }
45
+ return 'freeform mark';
46
+ }
47
+
48
+ function isCheck(points) {
49
+ // ✓: dips to a low point early on, then rises past where it started.
50
+ let low = 0;
51
+ for (let i = 1; i < points.length; i++) if (points[i][1] > points[low][1]) low = i;
52
+ const t = low / (points.length - 1);
53
+ const [sx, sy] = points[0];
54
+ const [lx, ly] = points[low];
55
+ const [ex, ey] = points[points.length - 1];
56
+ return t > 0.15 && t < 0.6 && ly - sy > 8 && ey < sy && ex > lx && lx > sx;
57
+ }
58
+
59
+ function isStraight(points) {
60
+ let len = 0;
61
+ for (let i = 1; i < points.length; i++) len += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);
62
+ const [sx, sy] = points[0];
63
+ const [ex, ey] = points[points.length - 1];
64
+ return len < 1.3 * Math.hypot(ex - sx, ey - sy);
65
+ }
66
+
67
+ // Two roughly straight strokes that cross each other with opposite slopes form an ✗,
68
+ // however wide or flat it is.
69
+ function isCross(a, b) {
70
+ if (!isStraight(a) || !isStraight(b)) return false;
71
+ const [p1, p2, p3, p4] = [a[0], a[a.length - 1], b[0], b[b.length - 1]];
72
+ const slope = (p, q) => Math.sign((q[0] - p[0]) * (q[1] - p[1]));
73
+ if (slope(p1, p2) === slope(p3, p4) || !slope(p1, p2) || !slope(p3, p4)) return false;
74
+ const side = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
75
+ return side(p1, p2, p3) !== side(p1, p2, p4) && side(p3, p4, p1) !== side(p3, p4, p2);
76
+ }
77
+
78
+ // Several strokes drawn together form one mark.
79
+ function describeMark(paths) {
80
+ const list = (paths || []).filter((p) => p && p.length);
81
+ if (!list.length) return 'mark';
82
+ if (list.length === 1) return isCheck(list[0]) && describeStroke(list[0]) !== 'circled' ? 'check mark (✓)' : describeStroke(list[0]);
83
+ if (list.length === 2 && isCross(list[0], list[1])) return 'cross (✗, crossed out)';
84
+ return `${list.length} strokes: ${list.map(describeStroke).join(', ')}`;
85
+ }
86
+
87
+ function viewportLabel(vp) {
88
+ const kind = vp.w < 768 ? 'mobile' : vp.w < 1100 ? 'tablet' : 'desktop';
89
+ return `${round(vp.w)}×${round(vp.h)} (${kind})`;
90
+ }
91
+
92
+ function strokeBounds(points) {
93
+ const xs = points.map((p) => p[0]);
94
+ const ys = points.map((p) => p[1]);
95
+ return { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) };
96
+ }
97
+
98
+ function describeElement(t) {
99
+ const bits = [];
100
+ if (t.rect) bits.push(`${round(t.rect.w)}×${round(t.rect.h)} at (${round(t.rect.x)}, ${round(t.rect.y)})`);
101
+ const s = t.styles || {};
102
+ if (s.font) bits.push(`font ${s.font}`);
103
+ if (s.color) bits.push(`color ${s.color}`);
104
+ if (s.background) bits.push(`bg ${s.background}`);
105
+ if (s.padding) bits.push(`padding ${s.padding}`);
106
+ if (s.radius) bits.push(`radius ${s.radius}`);
107
+ if (s.layout) bits.push(s.layout);
108
+ return bits.join(' · ');
109
+ }
110
+
111
+ function targetLine(t) {
112
+ let line = `\`${t.selector}\``;
113
+ if (t.html) line += ` — \`${t.html}\``;
114
+ if (t.text) line += ` “${t.text}”`;
115
+ return line;
116
+ }
117
+
118
+ const short = (t) => `\`${t.selector}\`${t.text ? ` (“${t.text.slice(0, 40)}${t.text.length > 40 ? '…' : ''}”)` : ''}`;
119
+
120
+ /**
121
+ * What a drawn mark asks for, in words: the gesture and its colour are the whole
122
+ * instruction when the reviewer didn't write anything.
123
+ */
124
+ function markMeaning(a) {
125
+ const shape = describeMark(a.stroke && a.stroke.paths);
126
+ const v = a.verdict;
127
+ const quote = a.quote ? `“${a.quote}”` : 'the underlined text';
128
+ if (/cross|scribble/.test(shape)) {
129
+ return v === 'idea'
130
+ ? 'Crossed out as a question: does this need to be here? Propose removing or replacing it.'
131
+ : 'Crossed out: remove this, or replace it with something that works better.';
132
+ }
133
+ if (/check mark/.test(shape)) return 'Ticked: approved, keep it as it is.';
134
+ if (/circled/.test(shape)) {
135
+ if (v === 'good') return 'Circled as good: keep this exactly as it is, and reuse its pattern elsewhere.';
136
+ if (v === 'idea') return 'Circled as an idea: this area could be better. Suggest an improvement, and apply it if it is clearly better.';
137
+ return 'Circled to fix: something inside the circled area is off. Look at the screenshot, find what breaks the design (spacing, alignment, size, contrast, wording) and fix it.';
138
+ }
139
+ if (/horizontal/.test(shape)) {
140
+ if (v === 'good') return `Underlined as good: this wording works (${quote}), keep it.`;
141
+ if (v === 'idea') return `Underlined as an idea: consider rewording or restyling ${quote}.`;
142
+ return `Underlined to fix: reword or restyle ${quote}.`;
143
+ }
144
+ if (a.arrow) {
145
+ return `Arrow from ${short(a.arrow.from)} to ${short(a.arrow.to)}: move, align or connect the first with the second.`;
146
+ }
147
+ if (/diagonal|vertical/.test(shape)) return 'A line pointing at this area: check the screenshot for what it relates.';
148
+ if (shape === 'dot') return `A dot on this spot: ${VERDICTS[v] ? VERDICTS[v].meaning : 'look here'}.`;
149
+ return `Marked as ${VERDICTS[v] ? VERDICTS[v].label : 'Idea'}: ${VERDICTS[v] ? VERDICTS[v].meaning : ''}.`;
150
+ }
151
+
152
+ // A box with no words: its ink colour is the instruction.
153
+ function boxMeaning(a) {
154
+ const what = a.region ? 'this area' : 'this element';
155
+ if (a.verdict === 'good') return `Boxed in green: keep ${what} as it is, and reuse its pattern.`;
156
+ if (a.verdict === 'idea') return `Boxed in amber: ${what} could be better. Suggest an improvement, and apply it if it is clearly better.`;
157
+ return `Boxed in red: ${what} needs to change. Look at the screenshot and fix what is off.`;
158
+ }
159
+
160
+ // Where we are in the turn-taking, and what the reviewer said about the last round's report.
161
+ function roundHeader(rounds) {
162
+ const list = rounds || [];
163
+ const last = list[list.length - 1];
164
+ if (!last) return [];
165
+ const out = [`**Round ${last.n}**${last.returnedAt ? ' (you closed it; new items below are redos or were sent since)' : `: the reviewer sent ${last.count} item${last.count === 1 ? '' : 's'}`}.`];
166
+ const prev = [...list].reverse().find((r) => r.returnedAt && (r.readingCorrection || (r.questions || []).some((q) => q.answer)));
167
+ if (prev) {
168
+ if (prev.readingCorrection) out.push(`- The reviewer corrected your reading of the page: “${prev.readingCorrection}”`);
169
+ for (const q of prev.questions || []) if (q.answer) out.push(`- You asked “${q.text}”, they answered: “${q.answer}”`);
170
+ }
171
+ out.push('');
172
+ return out;
173
+ }
174
+
175
+ function describeEdits(e) {
176
+ const where = e.scope === 'all' && e.selector ? `on **every** \`${e.selector}\`` : 'on this element';
177
+ const lines = [];
178
+ if (e.props && e.props.length) {
179
+ lines.push(`- **Proposed change** (${where}, previewed live by the reviewer): ${e.props.map((p) => `\`${p.prop}\` ${p.from || '?'} → **${p.to}**`).join('; ')}`);
180
+ }
181
+ if (e.text) lines.push(`- **Proposed text:** “${e.text.from}” → **“${e.text.to}”**`);
182
+ return lines;
183
+ }
184
+
185
+ function formatVariants(a, o) {
186
+ const out = [];
187
+ const tool = o.cli ? '`redline variants <id> --file options.json`' : 'the `propose_variants` tool';
188
+ if (a.chosen) {
189
+ const v = (a.variants || []).find((x) => x.key === a.chosen);
190
+ if (v) {
191
+ out.push(`- **The reviewer picked option ${v.key}, “${v.label}”.** Build it in the source (then resolve):`);
192
+ if (v.css) out.push(' ```css', ...v.css.replace(/&/g, (a.target && a.target.selector) || '&').split('\n').map((l) => ' ' + l), ' ```');
193
+ if (v.html) out.push(' ```html', ...v.html.split('\n').map((l) => ' ' + l), ' ```');
194
+ }
195
+ } else if (a.variants && a.variants.length) {
196
+ out.push(`- **Options proposed, waiting for the reviewer to pick:** ${a.variants.map((v) => `${v.key} “${v.label}”`).join(', ')}. Don't change the code for this item yet.`);
197
+ } else if (a.request) {
198
+ out.push(
199
+ `- **The reviewer asked for ${a.request.variants} options.** Don't change the code yet: propose them with ${tool}. ` +
200
+ 'Each option is `{label, css, html?, note?}`; in `css`, `&` stands for this element, and `html` replaces its contents. The reviewer previews them on the page and picks one.',
201
+ );
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function formatFiles(a, o) {
207
+ const dir = o.storeDir || '.redline';
208
+ const out = [];
209
+ const shots = { ...(a.shots || {}) };
210
+ delete shots.plain; // the same picture as "before" without the mark: for Review, not for the agent
211
+ const labels = { before: 'as reviewed', proposed: 'with the proposed change', after: 'after the fix' };
212
+ if (a.guard && a.guard.now && a.guard.now.status !== 'held' && a.guard.now.shot) shots['changed, now'] = a.guard.now.shot;
213
+ const shotList = Object.entries(shots).map(([k, p]) => `${labels[k] || k.replace('variant-', 'option ')}: \`${dir}/${p}\``);
214
+ if (shotList.length) out.push(`- **Screenshots:** ${shotList.join(' · ')}`);
215
+ const refs = (a.references || []).map((r) => (r.path ? `\`${dir}/${r.path}\`` : '(image kept in the browser)') + (r.name ? ` (${r.name})` : ''));
216
+ if (refs.length) out.push(`- **Reference images** (the reviewer wants it to feel like these): ${refs.join(', ')}`);
217
+ return out;
218
+ }
219
+
220
+ function formatAnnotation(a, n, o = {}) {
221
+ const v = verdictOf(a);
222
+ const kind = a.kind === 'stroke' ? `pen mark: ${describeMark(a.stroke && a.stroke.paths)}` : a.region ? 'box around an area' : 'box';
223
+ const status = a.status === 'resolved' ? ' · RESOLVED' : '';
224
+ const out = [`### ${n}. ${v.icon} ${v.label} · ${kind}${status} · id \`${a.id}\``];
225
+ if (a.target) out.push(`- **Target:** ${targetLine(a.target)}`);
226
+ const others = (a.covers || []).filter((c) => !a.target || c.selector !== a.target.selector);
227
+ if (others.length) out.push(`- **Also covers:** ${others.map(targetLine).join('; ')}`);
228
+ // A drawing is enough: its meaning comes first, and a comment is optional extra.
229
+ if (a.kind === 'stroke') out.push(`- **What the mark says:** ${markMeaning(a)}`);
230
+ else if (!a.comment) out.push(`- **What the box says:** ${boxMeaning(a)}`);
231
+ if (a.comment) out.push(`- **Comment:** ${a.comment.replace(/\n+/g, ' / ')}`);
232
+ if (a.region) out.push(`- **Area:** ${round(a.region.w)}×${round(a.region.h)} at (${round(a.region.x)}, ${round(a.region.y)}) inside the target`);
233
+ if (a.quote && /horizontal/.test(describeMark(a.stroke && a.stroke.paths))) out.push(`- **Underlined text:** “${a.quote}”`);
234
+ if (a.tags && a.tags.length) out.push(`- **About:** ${a.tags.join(', ')}`);
235
+ if (a.viewport) out.push(`- **Seen at:** ${viewportLabel(a.viewport)}${a.url ? ` · URL \`${a.url}\`` : ''}`);
236
+ if (a.uiState && a.uiState.length) out.push(`- **Page state:** ${a.uiState.join(', ')}`);
237
+ if (a.edits) out.push(...describeEdits(a.edits));
238
+ out.push(...formatVariants(a, o));
239
+ if (a.target) {
240
+ const details = describeElement(a.target);
241
+ if (details) out.push(`- **Element when noted:** ${details}`);
242
+ }
243
+ const points = a.kind === 'stroke' && a.stroke && (a.stroke.paths || []).flat();
244
+ if (points && points.length) {
245
+ const b = strokeBounds(points);
246
+ out.push(`- **Mark area:** x ${round(b.x)}–${round(b.x + b.w)}, y ${round(b.y)}–${round(b.y + b.h)} (page px)`);
247
+ }
248
+ if (a.history && a.history.length) {
249
+ out.push(`- **Reopened.** Already tried, and not enough: ${a.history.map((h) => `“${h}”`).join('; then ')}`);
250
+ }
251
+ // An approval that changed since the reviewer approved it: restore it.
252
+ const guard = a.guard && a.guard.now && a.guard.now.status !== 'held' && a.guard.now;
253
+ if (guard) {
254
+ out.push(`- **Changed since the reviewer approved it:** ${(guard.diff || []).join('; ')}. Put it back the way it was approved` +
255
+ `${guard.baseShot || (a.guard.base && a.guard.base.shot) ? ` (\`${o.storeDir || '.redline'}/${guard.baseShot || a.guard.base.shot}\`)` : ''}, without undoing other fixes.`);
256
+ }
257
+ out.push(...formatFiles(a, o));
258
+ if (a.section) out.push(`- **In section:** \`${a.section.selector}\`${a.section.heading ? ` “${a.section.heading}”` : ''}`);
259
+ const convo = a.thread || [];
260
+ if (convo.length) {
261
+ out.push('- **Conversation:**');
262
+ for (const m of convo) out.push(` - ${m.by === 'agent' ? 'you (agent)' : 'reviewer'}: ${m.text.replace(/\n+/g, ' / ')}`);
263
+ const last = convo[convo.length - 1];
264
+ if (last.by === 'you' && a.status !== 'resolved') out.push(' - _The reviewer is waiting for your answer._');
265
+ }
266
+ if (a.status === 'resolved' && a.resolution) out.push(`- **Resolution:** ${a.resolution}`);
267
+ return out.join('\n');
268
+ }
269
+
270
+ // The reviewer's taste so far: what they rejected is what the agent most needs to know.
271
+ function tasteSection(entries) {
272
+ const rejected = (entries || []).filter((e) => e.kind === 'redo' || e.kind === 'sent-back').slice(-4);
273
+ const kept = new Set((entries || []).filter((e) => e.kind === 'keep' || e.kind === 'accepted').map((e) => `${e.page}|${e.selector}`)).size;
274
+ if (!rejected.length && !kept) return [];
275
+ const out = [`**This reviewer's taste** (all of it in \`.redline/TASTE.md\`): ${kept} approval${kept === 1 ? '' : 's'} to keep intact.`];
276
+ for (const e of rejected) {
277
+ out.push(e.kind === 'redo'
278
+ ? `- Rejected on \`${e.selector || 'page'}\`: “${e.resolution || 'the fix'}”${e.reply ? `. They said: “${e.reply}”` : ''}`
279
+ : `- An approval on \`${e.selector || 'page'}\` broke and was sent back${e.diff ? ` (${e.diff.slice(0, 2).join('; ')})` : ''}`);
280
+ }
281
+ out.push('');
282
+ return out;
283
+ }
284
+
285
+ function pageOrder(annotations) {
286
+ const pages = [];
287
+ for (const a of annotations) if (!pages.includes(a.page)) pages.push(a.page);
288
+ return pages;
289
+ }
290
+
291
+ /**
292
+ * Render annotations as a Markdown brief an AI agent can act on.
293
+ * opts.includeResolved – include resolved items (default false)
294
+ * opts.cli – mention the `redline resolve` command (default false)
295
+ */
296
+ function formatMarkdown(annotations, opts) {
297
+ const o = opts || {};
298
+ // Drafts are the reviewer's work in progress (not sent yet); accepted fixes are done.
299
+ const list = annotations
300
+ .filter((a) => (o.includeDrafts || !a.draft) && (o.includeResolved || (a.status !== 'resolved' && !a.accepted)))
301
+ .slice()
302
+ .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
303
+ const counts = { good: 0, fix: 0, idea: 0 };
304
+ for (const a of list) counts[a.verdict in counts ? a.verdict : 'idea']++;
305
+
306
+ const out = ['# Design feedback', ''];
307
+ out.push(...roundHeader(o.rounds));
308
+ out.push(...tasteSection(o.taste));
309
+ if (!list.length) {
310
+ out.push('No open feedback. 🎉');
311
+ return out.join('\n') + '\n';
312
+ }
313
+ out.push(
314
+ `${list.length} item${list.length === 1 ? '' : 's'}: ${counts.fix} to fix, ${counts.good} marked good, ${counts.idea} ideas/questions.`,
315
+ '',
316
+ 'How to read this: the reviewer clicked or drew on the live page. **✓ Good** = keep it and reuse the pattern; ' +
317
+ '**✗ Fix** = change it; **? Idea** = a suggestion or question. Selectors and element details were captured at review time; ' +
318
+ 'match on text and classes if the markup has since changed. **Seen at** tells you the screen width (a mobile note is about the mobile layout) ' +
319
+ 'and **Page state** which toggles, tabs or menus were on. A **Proposed change** gives exact values the reviewer tried live on the page: ' +
320
+ 'apply them in the source (to every matching element when it says so). Look at the screenshots and reference images when listed. ' +
321
+ 'Don\'t regress anything marked Good while fixing the rest.',
322
+ );
323
+ const numbers = list.map((a) => a.n).filter(Boolean);
324
+ if (numbers.length && Math.max(...numbers) > numbers.length && !o.includeResolved) {
325
+ out.push('', 'Item numbers match the pins on the page; gaps are items that were resolved or deleted.');
326
+ }
327
+ if (o.cli) {
328
+ out.push('', 'After addressing an item, run `redline resolve <id> --note "what you changed"` so the reviewer sees it in the overlay. ' +
329
+ 'If your fix restructured the element, add `--target "<css selector>"` so the after-screenshot shows the new markup. ' +
330
+ 'If a mark is unclear, ask under it with `redline reply <id> "question"` instead of guessing. ' +
331
+ 'Before closing the round, run `redline verify <site folder>` (or `--url <dev server>`) and restore any approval it says changed. ' +
332
+ 'When you are done with the round, close it with `redline done --summary "what you did" --reading "how you read this page, in two sentences"`.');
333
+ } else {
334
+ out.push('', 'Resolve each item with `resolve`. If a mark is unclear, ask under it with `reply` instead of guessing. ' +
335
+ 'Before closing the round, call `check_approvals` and restore anything it says changed. ' +
336
+ 'When you are done with the round, close it with `finish_round` (a summary, how you read the page, and at most 3 questions).');
337
+ }
338
+ for (const page of pageOrder(list)) {
339
+ const items = list.filter((a) => a.page === page);
340
+ out.push('', `## Page \`${page}\``);
341
+ items.forEach((a, i) => out.push('', formatAnnotation(a, a.n || i + 1, o)));
342
+ }
343
+ return out.join('\n') + '\n';
344
+ }
345
+
346
+ export { VERDICTS, describeMark, describeStroke, formatMarkdown, markMeaning };
package/src/guards.js ADDED
@@ -0,0 +1,108 @@
1
+ // Guards: what the reviewer approved can't change behind their back.
2
+ //
3
+ // A green (keep) mark and an accepted fix are approvals. Each gets a baseline: a
4
+ // screenshot and a fingerprint of the element as approved (its size, its text,
5
+ // and the styles that make its look, for it and its descendants), taken in
6
+ // headless Chrome. When the agent closes a round, every approval is photographed
7
+ // again and the two fingerprints compared. A difference is reported in words
8
+ // ("font-size 48px → 40px on h1"), so the reviewer can send it back or keep the
9
+ // new look, and the agent knows exactly what to restore.
10
+
11
+ /** The phone width approvals made on a wider screen are also guarded at. */
12
+ export const MOBILE = 390;
13
+
14
+ /** An approval: kept on purpose (green) or a fix the reviewer accepted. Drafts aren't sent yet. */
15
+ export function isGuarded(a) {
16
+ return !!a && !a.draft && (!!a.accepted || a.verdict === 'good') && !(a.guard && a.guard.off);
17
+ }
18
+
19
+ /** Whether an item should have a baseline taken: approvals, and green marks as soon as they're drawn. */
20
+ export function wantsBaseline(a) {
21
+ return !!a && (!!a.accepted || a.verdict === 'good') && !(a.guard && (a.guard.base || a.guard.off));
22
+ }
23
+
24
+ /** Approved on a tablet or desktop: guard it on a phone too (unless it isn't shown there). */
25
+ export function wantsMobileBaseline(a) {
26
+ const w = (a && a.viewport && a.viewport.w) || 1280;
27
+ return !!a && w >= 768 && (!!a.accepted || a.verdict === 'good') && !(a.guard && (a.guard.mobile || a.guard.off));
28
+ }
29
+
30
+ /** The approval's verdict across widths: its own width first, then the phone. */
31
+ export function combineChecks(g) {
32
+ const parts = [[g.check, g.base, ''], [g.mobile && g.mobile.check, g.mobile && g.mobile.base, `at ${MOBILE}px: `]].filter(([c]) => c);
33
+ if (!parts.length) return g.now;
34
+ const off = parts.filter(([c]) => c.status !== 'held');
35
+ const lead = off[0] || parts[0];
36
+ const status = g.check && g.check.status === 'missing' ? 'missing' : off.length ? 'changed' : 'held';
37
+ return {
38
+ status,
39
+ at: parts.map(([c]) => c.at).sort().at(-1),
40
+ diff: off.flatMap(([c, , p]) => (c.status === 'missing' ? [`${p}it is no longer on the page`] : c.diff.map((d) => p + d))),
41
+ shot: lead[0].shot,
42
+ baseShot: lead[1] && lead[1].shot,
43
+ };
44
+ }
45
+
46
+ const cut = (s, n = 40) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
47
+ const near = (a, b, tol) => Math.abs((a || 0) - (b || 0)) <= tol;
48
+
49
+ /**
50
+ * Compare two fingerprints ({ nodes: [{ p, t, text, w, h, x, y, s: {prop: value} }], unstable? }).
51
+ * Returns the differences as short sentences, most telling first; [] when nothing changed.
52
+ * Parts that moved on their own while being fingerprinted are skipped (a node marked
53
+ * `v`, a text of null, a style of '*', a size or position of null), and with
54
+ * `ignore: ['text']` so are words and images (live content: feeds, dates, carousels).
55
+ */
56
+ export function diffFingerprints(base, now, { ignore = [] } = {}) {
57
+ if (!base || !Array.isArray(base.nodes) || !base.nodes.length) return [];
58
+ if (!now || !Array.isArray(now.nodes) || !now.nodes.length) return ['it is no longer on the page'];
59
+ const size = [], text = [], structure = [], moved = [];
60
+ const shift = new Map(); // path -> how far it moved, so children moving with their parent say nothing
61
+ const styles = new Map(); // "prop|from|to" -> [where]
62
+ const live = ignore.includes('text');
63
+ const shaky = base.unstable || now.unstable; // children came and went on their own
64
+ const nowAt = new Map(now.nodes.map((n) => [n.p, n]));
65
+ const baseAt = new Set(base.nodes.map((n) => n.p));
66
+ const [b0, n0] = [base.nodes[0], now.nodes[0]];
67
+ const known = (v) => v !== null && v !== undefined;
68
+ if (known(b0.w) && known(n0.w) && !live && (!near(b0.w, n0.w, 2) || !near(b0.h, n0.h, 2))) size.push(`size ${b0.w}×${b0.h} → ${n0.w}×${n0.h}`);
69
+ for (const b of base.nodes) {
70
+ const n = nowAt.get(b.p);
71
+ if (b.v || (n && n.v)) continue;
72
+ if (!n || n.t !== b.t) {
73
+ if (!shaky && !live) structure.push(`\`${b.t}\` is gone`);
74
+ continue;
75
+ }
76
+ if (!live && b.text !== null && n.text !== null && (b.text || '') !== (n.text || '')) text.push(`text “${cut(b.text || '')}” → “${cut(n.text || '')}”`);
77
+ // A line height in px follows the font size: when both moved by the same ratio, it's one change.
78
+ const ratio = (v) => parseFloat(v);
79
+ const bs = b.s || {}, ns = n.s || {};
80
+ const derivedLineHeight = bs['font-size'] !== ns['font-size'] && /px$/.test(bs['line-height'] || '') &&
81
+ Math.abs(ratio(ns['line-height']) / ratio(bs['line-height']) - ratio(ns['font-size']) / ratio(bs['font-size'])) < 0.02;
82
+ for (const [prop, from] of Object.entries(bs)) {
83
+ const to = ns[prop];
84
+ if (to === undefined || to === from || from === '*' || to === '*' || (prop === 'line-height' && derivedLineHeight) || (live && prop === 'src')) continue;
85
+ const key = `${prop}|${from}|${to}`;
86
+ if (!styles.has(key)) styles.set(key, []);
87
+ styles.get(key).push(b.t);
88
+ }
89
+ if (b !== b0 && !live && known(b.x) && known(n.x) && (!near(b.x, n.x, 3) || !near(b.y, n.y, 3))) {
90
+ const dx = Math.round(n.x - b.x), dy = Math.round(n.y - b.y);
91
+ shift.set(b.p, [dx, dy]);
92
+ const parent = shift.get(b.p.slice(0, b.p.lastIndexOf('.')));
93
+ if (parent && near(parent[0], dx, 3) && near(parent[1], dy, 3)) continue;
94
+ moved.push(`\`${b.t}\` moved ${[dx && `${Math.abs(dx)}px ${dx > 0 ? 'right' : 'left'}`, dy && `${Math.abs(dy)}px ${dy > 0 ? 'down' : 'up'}`].filter(Boolean).join(', ')}`);
95
+ }
96
+ }
97
+ if (!shaky && !live) for (const n of now.nodes) if (!baseAt.has(n.p) && !n.v) structure.push(`new \`${n.t}\``);
98
+ // One line per change, however many descendants inherit it.
99
+ const styleLines = [...styles.entries()].map(([key, where]) => {
100
+ const [prop, from, to] = key.split('|');
101
+ const on = [...new Set(where)];
102
+ return `\`${prop}\` ${cut(from, 30)} → ${cut(to, 30)} on \`${on[0]}\`${on.length > 1 ? ` (+${on.length - 1})` : ''}`;
103
+ });
104
+ // Moves follow from the other changes most of the time: only report them alone.
105
+ const lines = [...text, ...styleLines, ...size, ...structure, ...(text.length || styleLines.length || structure.length ? [] : moved)];
106
+ const unique = [...new Set(lines)];
107
+ return unique.length > 8 ? [...unique.slice(0, 7), `and ${unique.length - 7} more changes`] : unique;
108
+ }
package/src/inject.js ADDED
@@ -0,0 +1,15 @@
1
+ export const CLIENT_PATH = '/__redline/client.js';
2
+ export const SCRIPT_TAG = `<script src="${CLIENT_PATH}" defer data-redline></script>`;
3
+
4
+ /** Insert the overlay script into an HTML document, once, as late as possible. */
5
+ export function injectScript(html, tag = SCRIPT_TAG) {
6
+ if (html.includes('data-redline')) return html;
7
+ for (const close of [/<\/body>/gi, /<\/html>/gi]) {
8
+ const matches = [...html.matchAll(close)];
9
+ if (matches.length) {
10
+ const at = matches[matches.length - 1].index;
11
+ return html.slice(0, at) + tag + html.slice(at);
12
+ }
13
+ }
14
+ return html + tag;
15
+ }