@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.
@@ -0,0 +1,110 @@
1
+ // ---------------------------------------------------------------- keyboard
2
+
3
+ const HOVER_KEYS = ['ArrowUp', 'ArrowDown', 'Enter'];
4
+
5
+ // Select mode: ↑/↓ walk the highlight up and back down the tree, Enter comments on it.
6
+ function hoverKey(key) {
7
+ if (state.mode !== 'select' || !state.hoverEl || state.composer) return false;
8
+ if (key === 'ArrowUp') {
9
+ const parent = state.hoverEl.parentElement;
10
+ if (parent && parent !== document.documentElement) {
11
+ state.hoverStack.unshift(parent);
12
+ showHover(parent);
13
+ }
14
+ } else if (key === 'ArrowDown') {
15
+ if (state.hoverStack.length > 1) {
16
+ state.hoverStack.shift();
17
+ showHover(state.hoverStack[0]);
18
+ }
19
+ } else if (key === 'Enter') {
20
+ if (state.retarget) return retargetTo(state.hoverEl, { dx: 1, dy: 0 }), true;
21
+ const r = state.hoverEl.getBoundingClientRect();
22
+ openComposer({ el: state.hoverEl, draft: { kind: 'note', anchor: { dx: 1, dy: 0 } }, point: { x: r.right, y: r.top } });
23
+ hoverBox.style.display = 'none';
24
+ } else return false;
25
+ return true;
26
+ }
27
+
28
+ function inkKey(v) {
29
+ setVerdict(v);
30
+ toast(`Ink: ${inkOf(v).name}`);
31
+ }
32
+
33
+ function isTyping(e) {
34
+ const t = e.composedPath()[0];
35
+ return t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName));
36
+ }
37
+
38
+ addEventListener(
39
+ 'keydown',
40
+ (e) => {
41
+ state.viaKeyboard = true;
42
+ // The backslash key shows the page without Redline and back, from anywhere; while previewing, no other key is ours.
43
+ if (e.key === '\\' && !e.metaKey && !e.ctrlKey && !isTyping(e)) {
44
+ e.preventDefault();
45
+ return setPreviewMode(!state.preview);
46
+ }
47
+ if (state.preview) return;
48
+ if (checkKey(e)) return; // checking: one key per verdict
49
+ const mod = e.metaKey || e.ctrlKey;
50
+ // Ctrl/⌘+Enter saves the open note wherever focus is (e.g. after editing text on the page).
51
+ if (mod && e.key === 'Enter' && state.composer && !eventIsOurs(e)) {
52
+ e.preventDefault();
53
+ state.composer.commit();
54
+ return;
55
+ }
56
+ if (state.collapsed || isTyping(e) || e.altKey) return;
57
+ if (mod && e.key.toLowerCase() === 'z' && state.mode === 'pen' && state.lastStrokeIds.length) {
58
+ e.preventDefault();
59
+ const id = state.lastStrokeIds.pop();
60
+ const a = state.annotations.find((x) => x.id === id);
61
+ if (a) remove(a);
62
+ return;
63
+ }
64
+ if (mod) return;
65
+ // Enter right after drawing: write a note for that mark.
66
+ if (e.key === 'Enter' && !mod && !state.composer && noteFromChip()) return e.preventDefault();
67
+ if (HOVER_KEYS.includes(e.key) && state.mode === 'select' && !state.composer) {
68
+ // In Devices view the hovered element lives in a frame, which may not have focus.
69
+ if (state.devices) {
70
+ e.preventDefault();
71
+ return forwardToFrames({ type: 'hoverKey', key: e.key });
72
+ }
73
+ if (hoverKey(e.key)) return e.preventDefault();
74
+ }
75
+ const actions = {
76
+ v: () => setMode('browse'),
77
+ b: () => setMode('select'), // Box
78
+ s: () => setMode('select'), // Box (old muscle memory: Select)
79
+ p: () => setMode('pen'),
80
+ // Inks: red change · green keep · amber idea. The gesture usually says it
81
+ // (✓ keeps, ✗ changes), so these are for the rest.
82
+ 1: () => inkKey('fix'),
83
+ 2: () => inkKey('good'),
84
+ 3: () => inkKey('idea'),
85
+ h: toggleHidden,
86
+ d: toggleDevices,
87
+ e: () => hasPreviews() && togglePreviews(),
88
+ l: togglePanel,
89
+ c: copyPrompt,
90
+ // Innermost first: an open image, a pending move, the note, the panel, then select/pen mode.
91
+ Escape: () =>
92
+ menuEl ? closeMenu()
93
+ : galleryEl ? closeGallery()
94
+ : state.retarget ? ((state.retarget = null), toast('Move cancelled'))
95
+ : state.composer ? closeComposer(true)
96
+ : state.panel ? togglePanel()
97
+ : setMode('browse'),
98
+ };
99
+ const fn = actions[e.key] || actions[e.key.toLowerCase()];
100
+ if (fn && (state.mode !== 'browse' || e.key !== 'Escape' || state.composer || state.panel || galleryEl || menuEl)) {
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+ fn();
104
+ }
105
+ },
106
+ true,
107
+ );
108
+
109
+ // Anything done with the pointer after a key press is animated normally again.
110
+ addEventListener('pointerdown', () => (state.viaKeyboard = false), true);
@@ -0,0 +1,222 @@
1
+ // ---------------------------------------------------------------- panel
2
+
3
+ function togglePanel() {
4
+ if (FRAME) return post(window.parent, { type: 'key', action: 'panel' });
5
+ if (!state.panel && !state.viaKeyboard) {
6
+ panel.classList.add('opening');
7
+ setTimeout(() => panel.classList.remove('opening'), 700);
8
+ }
9
+ state.panel = !state.panel;
10
+ renderPanel();
11
+ renderBar();
12
+ }
13
+
14
+ // What else is attached to an item, at a glance.
15
+ function itemBadges(a) {
16
+ const badges = [];
17
+ if (a.edits) badges.push(`✎ ${a.edits.props.length + (a.edits.text ? 1 : 0)} edit${a.edits.props.length + (a.edits.text ? 1 : 0) === 1 ? '' : 's'}${a.edits.scope === 'all' ? ' · all similar' : ''}`);
18
+ if (a.chosen) badges.push(`Picked option ${a.chosen}`);
19
+ else if (a.variants && a.variants.length) badges.push(`${a.variants.length} options to review`);
20
+ else if (a.request) badges.push('Options requested');
21
+ if (a.references && a.references.length) badges.push(`${a.references.length} reference${a.references.length === 1 ? '' : 's'}`);
22
+ return badges.length ? h('div', {}, badges.map((b) => h('span', { class: 'badge' }, b))) : null;
23
+ }
24
+
25
+ function renderPanel() {
26
+ panel.hidden = !state.panel;
27
+ if (!state.panel) return;
28
+ const list = state.annotations;
29
+ // Grouped by whose turn it is: yours to send, with the agent, yours to check, done.
30
+ const groups = [
31
+ { key: 'check', title: 'Fixed · to review', items: list.filter((a) => a.status === 'resolved' && !a.accepted) },
32
+ { key: 'changed', title: 'Changed · you approved these', items: list.filter(guardBroken) },
33
+ { key: 'draft', title: 'Your marks · not sent yet', items: list.filter((a) => a.draft) },
34
+ { key: 'sent', title: 'With your agent', items: list.filter((a) => !a.draft && a.status !== 'resolved') },
35
+ { key: 'done', title: 'Accepted', items: list.filter((a) => a.accepted && !guardBroken(a)), collapsed: true },
36
+ ];
37
+ // What came back goes through Check; everything else opens its note.
38
+ const open = (a) => ((a.status === 'resolved' && !a.accepted) || guardBroken(a) ? startCheck(a.id) : focusAnnotation(a));
39
+ const kindOf = (a) => (a.kind === 'stroke' ? describeMark(a.stroke && a.stroke.paths).replace(/ \(.*\)$/, '') : a.region ? 'box' : 'element');
40
+ const item = (a) =>
41
+ // A div, not a button: it contains buttons of its own (thumbnails, Compare).
42
+ h('div', {
43
+ class: 'item',
44
+ style: `--i:${Math.min(list.indexOf(a), 10)}`,
45
+ role: 'button',
46
+ tabindex: '0',
47
+ onclick: () => open(a),
48
+ onkeydown: (e) => e.key === 'Enter' && e.target === e.currentTarget && open(a),
49
+ },
50
+ h('i', { class: 'ink-dot', style: `background:${COLORS[a.verdict]}` }),
51
+ h('span', {},
52
+ h('b', {}, a.comment || (a.kind === 'stroke' ? markMeaning(a).split(':')[0] : inkOf(a.verdict).name),
53
+ a.viewport && madeElsewhere(a) ? h('span', { class: 'width-tag' }, `${a.viewport.w}px`) : null),
54
+ h('code', {}, `${kindOf(a)} · ${a.target ? a.target.selector : 'page'}`),
55
+ itemBadges(a),
56
+ a.status === 'resolved' ? h('div', { class: 'res' }, a.resolution || 'Fixed') : null,
57
+ a.status === 'resolved' && a.shots ? thumbStrip(a) : null,
58
+ ),
59
+ );
60
+ const group = (g) =>
61
+ g.items.length
62
+ ? h('section', { class: 'group' },
63
+ h('h3', {}, g.title, h('span', { class: 'n' }, g.items.length)),
64
+ g.collapsed && !state.showDone
65
+ ? h('button', { class: 'btn small', onclick: () => ((state.showDone = true), renderPanel()) }, 'Show')
66
+ : g.items.map(item))
67
+ : null;
68
+ const empty = !list.length;
69
+ fill(panel,
70
+ h('header', {},
71
+ h('h2', {}, 'Review ', h('code', {}, PAGE)),
72
+ h('button', { class: 'btn icon', title: 'Close (L)', 'aria-label': 'Close notes', html: ICONS.close, onclick: togglePanel }),
73
+ ),
74
+ roundReport(),
75
+ list.some(madeElsewhere)
76
+ ? h('label', { class: 'elsewhere-toggle' },
77
+ h('input', { type: 'checkbox', checked: state.showElsewhere, onchange: (e) => ((state.showElsewhere = e.target.checked), positionMarks()) }),
78
+ `Show marks made at other screen widths`)
79
+ : null,
80
+ h('div', { class: 'items' },
81
+ empty
82
+ ? h('div', { class: 'empty', html: 'Nothing here yet.<br><kbd>P</kbd> draw on the page, or click something to say why.<br>Then press <b>Send</b>.' })
83
+ : groups.map(group),
84
+ ),
85
+ h('footer', {},
86
+ drafts().length
87
+ ? h('button', { class: 'btn primary', onclick: sendRound }, `Send ${drafts().length} to your agent`)
88
+ : h('div', { class: 'row' },
89
+ h('button', { class: 'btn', onclick: copyPrompt, html: ICONS.copy + ' Copy for AI' }),
90
+ h('button', { class: 'btn', onclick: downloadPrompt, html: ICONS.download + ' .md' })),
91
+ h('div', { class: 'hint' }, SERVER
92
+ ? 'Send hands your marks to your agent (.redline/FEEDBACK.md). It fixes them and reports back here.'
93
+ : 'No server: Send copies your marks for your AI chat.'),
94
+ ),
95
+ );
96
+ }
97
+
98
+ // What you've typed into the report but not saved yet. The panel re-renders whenever
99
+ // anything changes (a screenshot arrives, the agent resolves a mark), so the fields
100
+ // are rebuilt from this, and keep their focus.
101
+ let reportDraft = null; // { n, fields: { reading, 0, 1, 2 }, focus }
102
+
103
+ // The agent's report on the last round: what it did, how it reads the page
104
+ // (correctable), and at most three questions (answerable). One place, one time.
105
+ function roundReport() {
106
+ const r = state.rounds && state.rounds[state.rounds.length - 1];
107
+ if (!r || !r.returnedAt) return null;
108
+ if (!reportDraft || reportDraft.n !== r.n) reportDraft = { n: r.n, fields: {}, focus: null };
109
+ const draft = reportDraft;
110
+ const field = (key, saved, attrs) => {
111
+ const t = h('textarea', attrs);
112
+ t.value = key in draft.fields ? draft.fields[key] : saved || '';
113
+ t.addEventListener('input', () => (draft.fields[key] = t.value));
114
+ t.addEventListener('focus', () => (draft.focus = key));
115
+ t.addEventListener('blur', () => draft.focus === key && (draft.focus = null));
116
+ if (draft.focus === key) queueMicrotask(() => t.isConnected && (t.focus({ preventScroll: true }), t.setSelectionRange(t.value.length, t.value.length)));
117
+ return t;
118
+ };
119
+ const correction = field('reading', r.readingCorrection, { rows: 2, placeholder: 'Not quite? Say what this page is really for…' });
120
+ const answers = (r.questions || []).map((q, i) => field(i, q.answer, { rows: 1, placeholder: 'Your answer…' }));
121
+ const saveBtn = h('button', {
122
+ class: 'btn small primary',
123
+ onclick: async () => {
124
+ await store.answerRound(r.n, { readingCorrection: correction.value.trim(), answers: answers.map((t) => t.value.trim()) });
125
+ draft.fields = {};
126
+ toast('Your answers go out with the next round');
127
+ await load();
128
+ },
129
+ }, 'Save answers');
130
+ // How the round went, at a glance: fixes accepted first time, and whether approvals held.
131
+ const judged = (r.accepted || 0) + (r.redone || 0);
132
+ const checked = state.annotations.filter((a) => a.guard && a.guard.now && a.guard.now.at >= r.returnedAt);
133
+ const broken = checked.filter(guardBroken).length;
134
+ const stats = [
135
+ judged ? h('span', { class: 'stat' }, h('b', {}, `${Math.round((100 * (r.accepted || 0)) / judged)}%`), ' accepted first time') : null,
136
+ checked.length ? h('span', { class: `stat${broken ? ' bad' : ''}` }, h('b', {}, broken ? `${broken} of ${checked.length}` : String(checked.length)), broken ? ' approvals changed' : ` approval${checked.length === 1 ? '' : 's'} held`) : null,
137
+ ].filter(Boolean);
138
+ return h('section', { class: 'report' },
139
+ h('h3', {}, `Round ${r.n} report`),
140
+ stats.length ? h('div', { class: 'stats' }, stats) : null,
141
+ r.summary ? h('p', { class: 'summary' }, r.summary) : null,
142
+ r.reading ? h('div', { class: 'reading' }, h('b', {}, 'How I read this page'), h('p', {}, r.reading), correction) : null,
143
+ (r.questions || []).length
144
+ ? h('div', { class: 'questions' }, h('b', {}, 'Questions'), (r.questions || []).map((q, i) => h('label', {}, h('span', {}, q.text), answers[i])))
145
+ : null,
146
+ r.reading || (r.questions || []).length ? saveBtn : null,
147
+ );
148
+ }
149
+
150
+ function focusAnnotation(a) {
151
+ if (focusInDevices(a)) return;
152
+ const at = anchorRect(a);
153
+ const el = at.state === 'visible' ? anchorEl(a) : null;
154
+ if (innerWidth < 760 && state.panel) togglePanel(); // the panel covers the page on phones
155
+ if (madeElsewhere(a)) toast(`Made at ${a.viewport.w}px wide — resize to see it in place`);
156
+ if (el) el.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
157
+ setTimeout(() => {
158
+ positionMarks();
159
+ if (el) {
160
+ placeBox(focusBox, el.getBoundingClientRect());
161
+ focusBox.classList.remove('flash');
162
+ void focusBox.offsetWidth;
163
+ focusBox.classList.add('flash');
164
+ }
165
+ openComposer({ annotation: a, el, point: el ? pinPoint(a) : null });
166
+ }, el ? 350 : 0);
167
+ }
168
+
169
+ // ---------------------------------------------------------------- export
170
+
171
+ async function allAnnotations() {
172
+ try {
173
+ return await store.all();
174
+ } catch {
175
+ return state.annotations;
176
+ }
177
+ }
178
+
179
+ async function copyPrompt() {
180
+ const list = await allAnnotations();
181
+ const md = formatMarkdown(list, { cli: !!SERVER, includeDrafts: true });
182
+ const open = list.filter((a) => a.status !== 'resolved').length;
183
+ if (await writeClipboard(md)) {
184
+ toast(open ? `Copied ${open} item${open === 1 ? '' : 's'} — paste into your AI chat` : 'Nothing open to copy');
185
+ } else {
186
+ downloadText(md);
187
+ toast('Clipboard blocked — downloaded feedback.md instead');
188
+ }
189
+ }
190
+
191
+ async function writeClipboard(text) {
192
+ try {
193
+ await navigator.clipboard.writeText(text);
194
+ return true;
195
+ } catch {
196
+ // navigator.clipboard is missing on plain-http origins; the legacy path still works there.
197
+ const area = h('textarea', { style: 'position:fixed;opacity:0' });
198
+ area.value = text;
199
+ root.append(area);
200
+ area.select();
201
+ try {
202
+ return document.execCommand('copy');
203
+ } catch {
204
+ return false;
205
+ } finally {
206
+ area.remove();
207
+ }
208
+ }
209
+ }
210
+
211
+ async function downloadPrompt() {
212
+ downloadText(formatMarkdown(await allAnnotations(), { cli: !!SERVER, includeDrafts: true }));
213
+ }
214
+
215
+ function downloadText(md) {
216
+ const url = URL.createObjectURL(new Blob([md], { type: 'text/markdown' }));
217
+ const link = h('a', { href: url, download: 'redline-feedback.md' });
218
+ root.append(link);
219
+ link.click();
220
+ link.remove();
221
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
222
+ }
package/client/pen.js ADDED
@@ -0,0 +1,324 @@
1
+ // ---------------------------------------------------------------- pen mode
2
+
3
+ let drawing = null; // { id, points, smooth, path, fromPin, frame }
4
+
5
+ // Freehand input is noisy (a mouse reports whole pixels, so slow diagonals come in
6
+ // as a staircase). Each point is pulled part of the way from the previous smoothed
7
+ // point toward the pointer, a light stabilizer that removes jitter without lag you'd notice.
8
+ const STABILIZE = 0.45; // 0 = raw input, 1 = frozen
9
+ const MIN_STEP = 2.5; // px between kept points
10
+
11
+ function startStroke(e, fromPin) {
12
+ e.preventDefault();
13
+ ink.setPointerCapture(e.pointerId); // also keeps the pin's click from firing
14
+ const path = s('path', { stroke: COLORS[state.verdict], 'stroke-width': 4, 'stroke-opacity': '.85' });
15
+ ink.append(path);
16
+ const start = [e.clientX, e.clientY];
17
+ drawing = { id: e.pointerId, points: [start], smooth: start.slice(), path, fromPin, frame: 0, startedAt: Date.now() };
18
+ if (chip) hideMarkChip(true);
19
+ }
20
+
21
+ ink.addEventListener('pointerdown', (e) => {
22
+ if (state.mode !== 'pen' || e.button > 0) return;
23
+ startStroke(e);
24
+ });
25
+
26
+ ink.addEventListener('pointermove', (e) => {
27
+ if (!drawing || e.pointerId !== drawing.id) return;
28
+ const events = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
29
+ for (const ev of events.length ? events : [e]) {
30
+ const sm = drawing.smooth;
31
+ sm[0] += (ev.clientX - sm[0]) * (1 - STABILIZE);
32
+ sm[1] += (ev.clientY - sm[1]) * (1 - STABILIZE);
33
+ const last = drawing.points[drawing.points.length - 1];
34
+ if (Math.hypot(sm[0] - last[0], sm[1] - last[1]) >= MIN_STEP) drawing.points.push([sm[0], sm[1]]);
35
+ }
36
+ // Redraw at most once per frame, however fast the pointer reports.
37
+ if (!drawing.frame) {
38
+ const d = drawing;
39
+ d.frame = requestAnimationFrame(() => {
40
+ d.frame = 0;
41
+ d.path.setAttribute('d', smoothPath(d.points));
42
+ });
43
+ }
44
+ });
45
+
46
+ ink.addEventListener('pointerup', finishStroke);
47
+ ink.addEventListener('pointercancel', () => {
48
+ if (drawing) {
49
+ cancelAnimationFrame(drawing.frame);
50
+ drawing.path.remove();
51
+ }
52
+ drawing = null;
53
+ });
54
+
55
+ // Drop points that don't change the shape (Ramer–Douglas–Peucker), keeping marks light.
56
+ function simplify(points, tolerance = 0.7) {
57
+ if (points.length < 3) return points;
58
+ const [ax, ay] = points[0];
59
+ const [bx, by] = points[points.length - 1];
60
+ const len = Math.hypot(bx - ax, by - ay);
61
+ let far = 0, index = 0;
62
+ for (let i = 1; i < points.length - 1; i++) {
63
+ const [px, py] = points[i];
64
+ // Closed loops (a circle ends where it starts) have no line to measure against:
65
+ // use the distance from the start point instead.
66
+ const dist = len < 1 ? Math.hypot(px - ax, py - ay) : Math.abs((by - ay) * px - (bx - ax) * py + bx * ay - by * ax) / len;
67
+ if (dist > far) {
68
+ far = dist;
69
+ index = i;
70
+ }
71
+ }
72
+ if (far <= tolerance) return [points[0], points[points.length - 1]];
73
+ return [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)];
74
+ }
75
+
76
+ function bboxOf(points) {
77
+ const xs = points.map((p) => p[0]);
78
+ const ys = points.map((p) => p[1]);
79
+ const x = Math.min(...xs), y = Math.min(...ys);
80
+ return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
81
+ }
82
+
83
+ function overlapArea(r, b) {
84
+ const w = Math.min(r.right, b.x + b.w) - Math.max(r.left, b.x);
85
+ const h2 = Math.min(r.bottom, b.y + b.h) - Math.max(r.top, b.y);
86
+ return w > 0 && h2 > 0 ? w * h2 : 0;
87
+ }
88
+
89
+ // Work out which element a freehand mark is about, and which ones it covers.
90
+ function elementsUnder(points) {
91
+ const b = bboxOf(points);
92
+ const area = (r) => r.width * r.height;
93
+ const boxArea = Math.max(b.w * b.h, 1);
94
+ const shape = describeMark([points]);
95
+
96
+ // Underline: the text is just above the line, so look there, not at the line.
97
+ if (shape.startsWith('horizontal')) {
98
+ const hits = new Map();
99
+ for (let i = 1; i <= 9; i++) {
100
+ const el = pageElementAt(b.x + (b.w * i) / 10, b.y - 10);
101
+ if (el) hits.set(el, (hits.get(el) || 0) + 1);
102
+ }
103
+ const best = [...hits.entries()].sort((x, y) => y[1] - x[1])[0];
104
+ if (best && best[0] !== document.body) return { target: best[0], covers: [] };
105
+ }
106
+
107
+ const hits = new Map();
108
+ const probe = (x, y) => {
109
+ if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return;
110
+ const el = pageElementAt(x, y);
111
+ if (el) hits.set(el, (hits.get(el) || 0) + 1);
112
+ };
113
+ for (let i = 1; i <= 5; i++) for (let j = 1; j <= 5; j++) probe(b.x + (b.w * i) / 6, b.y + (b.h * j) / 6);
114
+ const step = Math.max(1, Math.floor(points.length / 12));
115
+ for (let i = 0; i < points.length; i += step) probe(points[i][0], points[i][1]);
116
+
117
+ // Elements the mark mostly encloses, largest first, without their own descendants.
118
+ const covers = [...hits.keys()]
119
+ .filter((el) => el !== document.body)
120
+ .map((el) => ({ el, r: el.getBoundingClientRect() }))
121
+ .filter(({ r }) => area(r) > 0 && overlapArea(r, b) / area(r) >= 0.5 && area(r) >= boxArea * 0.04)
122
+ .sort((x, y) => area(y.r) - area(x.r))
123
+ .filter((c, i, arr) => !arr.slice(0, i).some((p) => p.el.contains(c.el)))
124
+ .slice(0, 4)
125
+ .map((c) => c.el);
126
+ if (covers.length === 1) return { target: covers[0], covers };
127
+
128
+ // A small mark over a small thing (an ✗ on an icon that spills into padding):
129
+ // the element under its centre, if it's about the mark's size.
130
+ const center = pageElementAt(b.x + b.w / 2, b.y + b.h / 2);
131
+ if (center && center !== document.body) {
132
+ const r = center.getBoundingClientRect();
133
+ if (area(r) <= boxArea * 4 && overlapArea(r, b) >= Math.min(area(r), boxArea) * 0.4) return { target: center, covers };
134
+ }
135
+
136
+ // Otherwise the smallest element that contains the whole mark.
137
+ let container = center || document.body;
138
+ while (container.parentElement && container !== document.body) {
139
+ const r = container.getBoundingClientRect();
140
+ if (overlapArea(r, b) >= boxArea * 0.85 && r.width >= b.w * 0.9) break;
141
+ container = container.parentElement;
142
+ }
143
+ return { target: container, covers };
144
+ }
145
+
146
+ async function finishStroke(e) {
147
+ if (!drawing || e.pointerId !== drawing.id) return;
148
+ cancelAnimationFrame(drawing.frame);
149
+ const { path, fromPin } = drawing;
150
+ // End exactly where the pointer lifted (the stabilizer trails slightly), then thin out.
151
+ const raw = drawing.points;
152
+ const end = [e.clientX, e.clientY];
153
+ const lastPt = raw[raw.length - 1];
154
+ if (Math.hypot(end[0] - lastPt[0], end[1] - lastPt[1]) > 0.5) raw.push(end);
155
+ const points = simplify(raw);
156
+ const startedAt = drawing.startedAt;
157
+ drawing = null;
158
+ path.setAttribute('d', smoothPath(points));
159
+ const b = bboxOf(points);
160
+ if (points.length < 2 || Math.max(b.w, b.h) < 6) {
161
+ path.remove();
162
+ // A press on a pin that didn't move is a click: open it. Anywhere else, a click
163
+ // boxes the element under the pen and asks why (the Box tool, without switching).
164
+ if (fromPin) (fromPin.status === 'resolved' && !fromPin.accepted) || guardBroken(fromPin) ? startCheck(fromPin.id) : openComposer({ annotation: fromPin });
165
+ else if (state.composer) closeComposer();
166
+ else boxAt(e.clientX, e.clientY);
167
+ return;
168
+ }
169
+ // Saves run one after another, so a quick second stroke sees the first one saved.
170
+ penQueue = penQueue.then(() => saveStroke(points, path, b, startedAt)).catch(() => {});
171
+ }
172
+
173
+ // The shape carries the verdict, so there's rarely a colour to pick: a tick keeps,
174
+ // a cross changes (or questions, in amber). Everything else takes the current ink.
175
+ function gestureInk(shape, ink) {
176
+ if (/check mark/.test(shape)) return 'good';
177
+ if (/cross|scribble/.test(shape)) return ink === 'idea' ? 'idea' : 'fix';
178
+ return ink;
179
+ }
180
+
181
+ // A click with the pen: box the element under it and open its note.
182
+ function boxAt(x, y) {
183
+ const el = pageElementAt(x, y);
184
+ if (!el) return;
185
+ const r = el.getBoundingClientRect();
186
+ const anchor = { dx: r.width ? (x - r.left) / r.width : 0, dy: r.height ? (y - r.top) / r.height : 0 };
187
+ if (state.retarget) return retargetTo(el, anchor);
188
+ openComposer({ el, draft: { kind: 'note', anchor }, point: { x, y } });
189
+ }
190
+
191
+ let penQueue = Promise.resolve();
192
+ let lastMark = null; // { id, ink, endedAt, box } — the mark a quick follow-up stroke may join
193
+
194
+ async function saveStroke(points, path, b, startedAt) {
195
+ // A stroke started right after the previous one ended, over it (the second line
196
+ // of an ✗), joins it. Anything later or elsewhere is its own mark.
197
+ // Compared in page coordinates, so scrolling in between can't fake an overlap.
198
+ const prev = lastMark && state.annotations.find((x) => x.id === lastMark.id);
199
+ const m = 40;
200
+ // Same colour, drawn right after, and really over it (an ✗'s second line crosses
201
+ // the first); a stroke beside the last mark, or in a new colour, is a new mark.
202
+ const px = b.x + scrollX, py = b.y + scrollY;
203
+ const lb = lastMark && lastMark.box;
204
+ const overlap = lb ? Math.max(0, Math.min(px + b.w, lb.x + lb.w) - Math.max(px, lb.x) + m) * Math.max(0, Math.min(py + b.h, lb.y + lb.h) - Math.max(py, lb.y) + m) : 0;
205
+ const smaller = lb ? Math.min((b.w + m) * (b.h + m), (lb.w + m) * (lb.h + m)) : 1;
206
+ const joinable = !!prev && lastMark.ink === state.verdict && startedAt - lastMark.endedAt < 1500 && overlap / smaller >= 0.35;
207
+ const { target, covers } = elementsUnder(joinable ? [...strokePaths(prev).flat(), ...points] : points);
208
+ const r = target.getBoundingClientRect();
209
+ const page = (pts) => pts.map(([x, y]) => [fmt(x + scrollX), fmt(y + scrollY)]);
210
+ const local = (pts) => pts.map(([x, y]) => [fmt(x - r.left), fmt(y - r.top)]);
211
+ const viewPaths = joinable ? [...strokePaths(prev), points] : [points];
212
+ const shape = describeMark(viewPaths);
213
+ const data = {
214
+ kind: 'stroke',
215
+ verdict: gestureInk(shape, state.verdict),
216
+ target: describeEl(target),
217
+ covers: covers.map(describeEl),
218
+ stroke: { paths: viewPaths.map(page), local: viewPaths.map(local), width: 4 },
219
+ page: PAGE,
220
+ ...captureContext(target),
221
+ };
222
+ data.quote = shape.startsWith('horizontal') ? textAlong(points) : '';
223
+ // An arrow relates two things: record what it starts on and what it points at.
224
+ if (shape.startsWith('diagonal') || shape.startsWith('vertical')) {
225
+ const from = pageElementAt(points[0][0], points[0][1]);
226
+ const endPt = points[points.length - 1];
227
+ const to = pageElementAt(endPt[0], endPt[1]);
228
+ if (from && to && from !== to && from !== document.body && to !== document.body) {
229
+ data.arrow = { from: describeEl(from), to: describeEl(to) };
230
+ }
231
+ }
232
+ try {
233
+ const saved = joinable ? await store.update(prev.id, data) : await store.create({ ...data, comment: '', tags: [], draft: true });
234
+ if (!joinable) state.lastStrokeIds.push(saved.id);
235
+ const box = bboxOf(viewPaths.flat());
236
+ lastMark = { id: saved.id, ink: state.verdict, endedAt: Date.now(), box: { ...box, x: box.x + scrollX, y: box.y + scrollY } };
237
+ await load();
238
+ path.remove();
239
+ // Drawing never opens anything: a small receipt appears just outside the mark.
240
+ showMarkChip(saved, bboxOf(viewPaths.flat()));
241
+ } catch (err) {
242
+ path.remove();
243
+ toast('Could not save mark: ' + err.message);
244
+ }
245
+ }
246
+
247
+ // ---- the mark chip: a quiet receipt at the end of a stroke
248
+
249
+ let chip = null; // { el, a, point, timer }
250
+
251
+ function hideMarkChip(instant) {
252
+ if (!chip) return;
253
+ const el = chip.el;
254
+ clearTimeout(chip.timer);
255
+ chip = null;
256
+ if (instant || state.viaKeyboard) return el.remove();
257
+ el.classList.add('out');
258
+ setTimeout(() => el.remove(), 130);
259
+ }
260
+
261
+ function armChip(ms) {
262
+ clearTimeout(chip.timer);
263
+ chip.timer = setTimeout(() => hideMarkChip(), ms);
264
+ }
265
+
266
+ /** Open the note for the mark in the chip (click "Note", or press Enter). Returns true if there was one. */
267
+ function noteFromChip() {
268
+ if (!chip) return false;
269
+ const { a, point } = chip;
270
+ hideMarkChip(true);
271
+ const fresh = state.annotations.find((x) => x.id === a.id) || a;
272
+ openComposer({ annotation: fresh, point, compact: true });
273
+ return true;
274
+ }
275
+
276
+ // Two or three words for the chip; the brief has the full description.
277
+ function gestureName(paths) {
278
+ const shape = describeMark(paths);
279
+ if (/cross|scribble/.test(shape)) return 'crossed out';
280
+ if (/check/.test(shape)) return 'ticked';
281
+ if (/circled/.test(shape)) return 'circled';
282
+ if (/horizontal/.test(shape)) return 'underlined';
283
+ if (/diagonal|vertical/.test(shape)) return 'line';
284
+ if (shape === 'dot') return 'dot';
285
+ return 'mark';
286
+ }
287
+
288
+ function showMarkChip(a, box) {
289
+ const point = { x: box.x + box.w, y: box.y + box.h };
290
+ hideMarkChip(true);
291
+ const order = ['good', 'fix', 'idea'];
292
+ const dot = h('button', { class: 'chip-dot', type: 'button' });
293
+ const paint = () => {
294
+ dot.style.background = COLORS[a.verdict];
295
+ dot.title = `${VERDICTS[a.verdict].label}: click to change`;
296
+ dot.setAttribute('aria-label', `Verdict ${VERDICTS[a.verdict].label}, click to change`);
297
+ };
298
+ dot.addEventListener('click', async () => {
299
+ a.verdict = order[(order.indexOf(a.verdict) + 1) % order.length];
300
+ paint();
301
+ armChip(4000);
302
+ await store.update(a.id, { verdict: a.verdict });
303
+ await load();
304
+ });
305
+ paint();
306
+ const el = h('div', { class: 'mark-chip', role: 'status' },
307
+ dot,
308
+ h('span', { class: 'chip-label' }, gestureName(a.stroke && a.stroke.paths)),
309
+ h('button', { type: 'button', class: 'chip-btn', title: 'Add a note to this mark (Enter)', onclick: noteFromChip }, 'Note'),
310
+ h('button', { type: 'button', class: 'chip-btn icon', title: 'Delete this mark', 'aria-label': 'Delete mark', html: ICONS.close, onclick: () => (hideMarkChip(true), remove(a)) }),
311
+ );
312
+ root.append(el);
313
+ // Below the mark's bottom-right corner (or above it, near the screen edge), never on the ink.
314
+ const w = el.offsetWidth, hgt = el.offsetHeight;
315
+ const below = box.y + box.h + 10;
316
+ const top = below + hgt < innerHeight - 70 ? below : box.y - hgt - 10; // stay clear of the toolbar
317
+ el.style.left = Math.min(Math.max(8, box.x + box.w - w), innerWidth - w - 8) + 'px';
318
+ el.style.top = Math.min(Math.max(8, top), innerHeight - hgt - 8) + 'px';
319
+ chip = { el, a, point, timer: 0 };
320
+ // Stays while you look at it; otherwise fades after a few seconds.
321
+ el.addEventListener('pointerenter', () => clearTimeout(chip && chip.timer));
322
+ el.addEventListener('pointerleave', () => chip && chip.el === el && armChip(2000));
323
+ armChip(4000);
324
+ }