@comty.io/core 0.1.5 → 0.1.7

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/dev.mjs ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import { createServer, request as httpRequest } from 'node:http';
3
+ import { spawn } from 'node:child_process';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { realpathSync } from 'node:fs';
6
+ import { resolve, delimiter } from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { createCompanion, isLoopback } from './companion.mjs';
10
+
11
+ export async function startDev({ cwd = process.cwd(), target = 'http://127.0.0.1:5173', port = 4387, provider = 'codex', model, projectKey, apiUrl, command = [] } = {}) {
12
+ const upstream = new URL(target);
13
+ if (upstream.protocol !== 'http:' || !isLoopback(upstream.hostname) || upstream.username || upstream.password || upstream.pathname !== '/' || upstream.search || upstream.hash) {
14
+ throw new Error('--target must be a loopback HTTP origin, e.g. http://127.0.0.1:5173');
15
+ }
16
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid --port');
17
+ const runtime = await readFile(new URL('./browser.js', import.meta.url));
18
+ const style = await readFile(new URL('./style.js', import.meta.url));
19
+ const anchors = await readFile(new URL('./anchors.js', import.meta.url));
20
+ const capture = await readFile(new URL('./capture.js', import.meta.url));
21
+ const captureEngine = await readFile(new URL('./capture-engine.js', import.meta.url));
22
+ const captureResources = await readFile(new URL('./capture-resources.js', import.meta.url));
23
+ const comments = await readFile(new URL('./comments.js', import.meta.url));
24
+ const measurements = await readFile(new URL('./measurements.js', import.meta.url));
25
+ const markup = await readFile(new URL('./inspect-markup.js', import.meta.url));
26
+ let companion, origin, child, closing, finished;
27
+ const sockets = new Set();
28
+ const proxies = new Set();
29
+ const server = createServer((request, response) => {
30
+ if (!origin || request.headers.host !== new URL(origin).host) {
31
+ response.writeHead(403); response.end('Comty is available on its loopback URL only'); return;
32
+ }
33
+ let url;
34
+ try { url = new URL(request.url, origin); }
35
+ catch { response.writeHead(400); response.end('Invalid request target'); return; }
36
+ if (url.origin !== origin) { response.writeHead(400); response.end('Invalid request target'); return; }
37
+ if (url.pathname.startsWith('/__comty/')) {
38
+ if (request.headers.origin && request.headers.origin !== origin) {
39
+ response.writeHead(403); response.end(); return;
40
+ }
41
+ if (request.method === 'GET' && ['/__comty/browser.js', '/__comty/style.js', '/__comty/anchors.js', '/__comty/capture.js', '/__comty/capture-engine.js', '/__comty/capture-resources.js', '/__comty/comments.js', '/__comty/measurements.js', '/__comty/inspect-markup.js', '/__comty/start.js'].includes(url.pathname)) {
42
+ response.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
43
+ response.end(url.pathname.endsWith('/browser.js') ? runtime : url.pathname.endsWith('/style.js') ? style : url.pathname.endsWith('/anchors.js') ? anchors : url.pathname.endsWith('/capture.js') ? capture : url.pathname.endsWith('/capture-engine.js') ? captureEngine : url.pathname.endsWith('/capture-resources.js') ? captureResources : url.pathname.endsWith('/comments.js') ? comments : url.pathname.endsWith('/measurements.js') ? measurements : url.pathname.endsWith('/inspect-markup.js') ? markup :
44
+ `import { initComty } from '/__comty/browser.js';\ninitComty(${JSON.stringify({ projectKey, apiUrl, agent: { url: `${origin}/__comty`, token: companion.token } })}).catch(console.error);`);
45
+ return;
46
+ }
47
+ void companion.handle(request, response); return;
48
+ }
49
+ const destination = new URL(upstream);
50
+ destination.pathname = url.pathname; destination.search = url.search;
51
+ const proxy = httpRequest(destination, {
52
+ method: request.method,
53
+ headers: { ...request.headers, host: upstream.host, 'accept-encoding': 'identity' },
54
+ }, incoming => {
55
+ const headers = { ...incoming.headers };
56
+ if (headers.location?.startsWith(upstream.origin)) headers.location = headers.location.replace(upstream.origin, origin);
57
+ if (!headers['content-type']?.includes('text/html') || request.method === 'HEAD') {
58
+ response.writeHead(incoming.statusCode, headers); incoming.pipe(response); return;
59
+ }
60
+ // ponytail: buffer development HTML for injection; use the Vite integration for streamed HTML.
61
+ const chunks = []; let length = 0;
62
+ incoming.on('data', chunk => {
63
+ length += chunk.length;
64
+ if (length > 8 * 1024 * 1024) incoming.destroy(new Error('HTML exceeds the 8 MiB development limit'));
65
+ else chunks.push(chunk);
66
+ });
67
+ incoming.on('error', error => { if (!response.headersSent) response.writeHead(502); response.end(error.message); });
68
+ incoming.on('end', () => {
69
+ if (incoming.headers['content-encoding'] && incoming.headers['content-encoding'] !== 'identity') {
70
+ response.writeHead(502); response.end('Comty requires uncompressed development HTML'); return;
71
+ }
72
+ const html = Buffer.concat(chunks).toString('utf8');
73
+ const script = '<script type="module" src="/__comty/start.js"></script>';
74
+ const injected = /<head(?:\s[^>]*)?>/i.test(html) ? html.replace(/<head(?:\s[^>]*)?>/i, match => match + script) : script + html;
75
+ delete headers['content-length']; delete headers.etag; delete headers['content-encoding'];
76
+ headers['cache-control'] = 'no-store';
77
+ response.writeHead(incoming.statusCode, headers); response.end(injected);
78
+ });
79
+ });
80
+ proxies.add(proxy); proxy.on('close', () => proxies.delete(proxy));
81
+ proxy.on('error', () => {
82
+ if (!response.headersSent) response.writeHead(502, { 'Content-Type': 'text/plain' });
83
+ response.end(`Development server unavailable at ${upstream.origin}. Check its terminal output and reload.`);
84
+ });
85
+ request.on('aborted', () => proxy.destroy());
86
+ response.on('close', () => proxy.destroy());
87
+ request.pipe(proxy);
88
+ });
89
+ server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
90
+ server.on('upgrade', (request, socket, head) => {
91
+ if (request.headers.host !== new URL(origin).host || (request.headers.origin && request.headers.origin !== origin)) return socket.destroy();
92
+ let url;
93
+ try { url = new URL(request.url, origin); } catch { return socket.destroy(); }
94
+ if (url.origin !== origin || url.pathname.startsWith('/__comty/')) return socket.destroy();
95
+ const destination = new URL(upstream);
96
+ destination.pathname = url.pathname; destination.search = url.search;
97
+ const proxy = httpRequest(destination, { headers: { ...request.headers, host: upstream.host } });
98
+ proxies.add(proxy); proxy.on('close', () => proxies.delete(proxy));
99
+ proxy.on('upgrade', (response, upstreamSocket, upstreamHead) => {
100
+ sockets.add(upstreamSocket); upstreamSocket.on('close', () => sockets.delete(upstreamSocket));
101
+ socket.write(`HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\n${response.rawHeaders.reduce((text, value, index, all) => index % 2 ? text : text + value + ': ' + all[index + 1] + '\r\n', '')}\r\n`);
102
+ if (upstreamHead.length) socket.write(upstreamHead);
103
+ if (head.length) upstreamSocket.write(head);
104
+ socket.pipe(upstreamSocket).pipe(socket);
105
+ socket.on('error', () => upstreamSocket.destroy());
106
+ upstreamSocket.on('error', () => socket.destroy());
107
+ socket.on('close', () => upstreamSocket.destroy());
108
+ upstreamSocket.on('close', () => socket.destroy());
109
+ });
110
+ proxy.on('response', response => { response.resume(); socket.destroy(); });
111
+ proxy.on('error', () => socket.destroy());
112
+ proxy.end();
113
+ });
114
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
115
+ origin = `http://127.0.0.1:${server.address().port}`;
116
+ if (new URL(origin).port === upstream.port) { server.close(); throw new Error('Comty and the application must use different ports'); }
117
+ try { companion = createCompanion({ cwd, origin, provider, model }); }
118
+ catch (error) { server.close(); throw error; }
119
+ async function close() {
120
+ if (closing) return closing;
121
+ closing = (async () => {
122
+ const kill = signal => {
123
+ if (!child?.pid) return;
124
+ try {
125
+ if (process.platform === 'win32') child.kill(signal);
126
+ else process.kill(-child.pid, signal);
127
+ } catch (error) { if (error.code !== 'ESRCH') throw error; }
128
+ };
129
+ kill('SIGTERM');
130
+ const force = setTimeout(() => kill('SIGKILL'), 2000);
131
+ for (const proxy of proxies) proxy.destroy();
132
+ for (const socket of sockets) socket.destroy();
133
+ await Promise.all([companion.close(), new Promise(resolve => server.close(resolve)),
134
+ child && child.exitCode === null && child.signalCode === null ? new Promise(resolve => child.once('close', resolve)) : Promise.resolve()]);
135
+ clearTimeout(force);
136
+ })();
137
+ return closing;
138
+ }
139
+ if (command.length) {
140
+ child = spawn(command[0], command.slice(1), {
141
+ cwd, stdio: 'inherit', detached: process.platform !== 'win32', env: { ...process.env, PATH: `${resolve(cwd, 'node_modules/.bin')}${delimiter}${process.env.PATH || ''}` },
142
+ });
143
+ child.on('error', error => { console.error(`Comty could not start ${command[0]}: ${error.message}`); });
144
+ finished = new Promise(resolve => child.once('close', code => resolve(Number.isInteger(code) && code >= 0 ? code : 1))).then(async code => {
145
+ await close();
146
+ return code;
147
+ });
148
+ finished.catch(error => console.error(`Comty shutdown failed: ${error.message}`));
149
+ }
150
+ return { origin, close, finished };
151
+ }
152
+
153
+ async function main() {
154
+ const args = process.argv.slice(2);
155
+ if (!args.length || args.includes('--help')) {
156
+ console.log('Usage: comty dev [--agent codex|claude|cursor|gemini|grok] [--model MODEL] [--target http://127.0.0.1:5173] [--port 4387] [--project-key KEY --api-url URL] -- vite --strictPort'); return;
157
+ }
158
+ if (args.shift() !== 'dev') throw new Error('Expected comty dev. Use --help for usage.');
159
+ const split = args.indexOf('--');
160
+ const { values } = parseArgs({ args: split < 0 ? args : args.slice(0, split), options: {
161
+ target: { type: 'string' }, port: { type: 'string' }, agent: { type: 'string' }, model: { type: 'string' }, 'project-key': { type: 'string' }, 'api-url': { type: 'string' },
162
+ } });
163
+ const session = await startDev({ target: values.target, port: values.port === undefined ? 4387 : Number(values.port), provider: values.agent, model: values.model,
164
+ projectKey: values['project-key'], apiUrl: values['api-url'], command: split < 0 ? [] : args.slice(split + 1) });
165
+ console.log(`Comty: ${session.origin} — open this URL to annotate with the local agent.`);
166
+ for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { void session.close().catch(console.error); });
167
+ if (session.finished) process.exitCode = await session.finished;
168
+ }
169
+
170
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
171
+ main().catch(error => { console.error(`Comty: ${error.message}`); process.exitCode = 1; });
172
+ }
package/src/gemini.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { runAcp } from './acp.mjs';
2
+
3
+ export const runGemini = options => runAcp({ ...options, provider: 'gemini' });
package/src/grok.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { runAcp } from './acp.mjs';
2
+
3
+ export const runGrok = options => runAcp({ ...options, provider: 'grok' });
@@ -0,0 +1,283 @@
1
+ // Page-coordinate drawings stay attached to the document when the viewport scrolls.
2
+ export function createInspectMarkup({ root, icon, onChange, capture, onError }) {
3
+ const ns = 'http://www.w3.org/2000/svg';
4
+ const layer = document.createElementNS(ns, 'svg');
5
+ layer.classList.add('comty-markup-layer');
6
+ layer.setAttribute('aria-hidden', 'true');
7
+ const drawings = document.createElementNS(ns, 'g');
8
+ layer.append(drawings);
9
+ root.append(layer);
10
+ const surface = document.createElement('div');
11
+ surface.className = 'comty-markup-surface';
12
+ surface.hidden = true;
13
+ const selection = document.createElement('div');
14
+ selection.className = 'comty-capture-selection';
15
+ selection.hidden = true;
16
+ surface.append(selection);
17
+ root.append(surface);
18
+ const panel = document.createElement('section');
19
+ panel.className = 'comty-tool-panel comty-markup-panel';
20
+ panel.id = 'comty-markup-panel';
21
+ panel.setAttribute('aria-label', 'Annotations and screenshot');
22
+ panel.hidden = true;
23
+ root.append(panel);
24
+ const shapes = {
25
+ pencil: ['Pencil', '<path d="m16 3 5 5L8 21H3v-5ZM14 5l5 5"/>'],
26
+ rectangle: ['Rectangle', '<rect x="4" y="5" width="16" height="14" rx="1"/>'],
27
+ ellipse: ['Ellipse', '<ellipse cx="12" cy="12" rx="9" ry="7"/>'],
28
+ arrow: ['Arrow', '<path d="M4 20 20 4M8 4h12v12"/>'],
29
+ };
30
+ const palette = [
31
+ ['Red', '--comty-measure'], ['Yellow', '--comty-markup-yellow'],
32
+ ['Green', '--comty-markup-green'], ['Blue', '--comty-accent'],
33
+ ['Black', '--comty-ink'], ['White', '--comty-paper'],
34
+ ];
35
+ const tokenColor = token => {
36
+ const value = getComputedStyle(root).getPropertyValue(token).trim();
37
+ return /^#[\da-f]{3}$/i.test(value) ? '#' + [...value.slice(1)].map(char => char + char).join('') : value;
38
+ };
39
+ panel.setAttribute('aria-label', 'Annotation tools');
40
+ panel.innerHTML = [
41
+ '<div class="comty-markup-controls" role="group" aria-label="Drawing tools">',
42
+ ...Object.entries(shapes).map(([key, [label, paths]]) => `<button type="button" data-shape="${key}" aria-label="${label}" title="${label}" aria-pressed="false">${icon(paths)}</button>`),
43
+ '<span class="comty-markup-divider" aria-hidden="true"></span>',
44
+ `<button type="button" data-undo aria-label="Undo annotation" title="Undo · ⌘/Ctrl Z">${icon('<path d="m9 4-5 5 5 5M4 9h10a6 6 0 0 1 0 12"/>')}</button>`,
45
+ `<button type="button" data-clear aria-label="Clear annotations" title="Clear annotations">${icon('<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/>')}</button></div>`,
46
+ '<div class="comty-markup-options"><div class="comty-markup-strokes" role="group" aria-label="Stroke width">',
47
+ ...[[2, 'Thin'], [3, 'Medium'], [5, 'Thick']].map(([width, label]) => `<button type="button" data-stroke="${width}" aria-label="${label} stroke" title="${label}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m7 7 10 10" stroke="currentColor" stroke-width="${width}" stroke-linecap="round"/></svg></button>`),
48
+ '</div><span class="comty-markup-divider" aria-hidden="true"></span><div class="comty-markup-colors" role="group" aria-label="Annotation colors">',
49
+ ...palette.map(([name, token]) => `<button type="button" data-color="${token}" aria-label="${name} ink" title="${name}" aria-pressed="false"><span class="comty-markup-swatch" aria-hidden="true"></span></button>`),
50
+ '<button type="button" class="comty-markup-picker" title="Custom color" aria-label="Custom annotation color" aria-expanded="false" aria-controls="comty-markup-color"></button>',
51
+ '</div></div>',
52
+ '<div class="comty-markup-color" id="comty-markup-color" role="group" aria-label="Custom color picker" hidden>',
53
+ '<div class="comty-markup-color-field" tabindex="0" role="group" aria-label="Saturation and brightness" aria-description="Left and right arrows adjust saturation. Up and down arrows adjust brightness."><span aria-hidden="true"></span></div>',
54
+ '<input class="comty-markup-hue" type="range" min="0" max="360" step="1" aria-label="Hue">',
55
+ '<label class="comty-markup-hex"><span class="comty-markup-color-preview" aria-hidden="true"></span><span>HEX</span><input type="text" aria-label="Hex color" maxlength="7" spellcheck="false" autocomplete="off" autocapitalize="off"></label>',
56
+ '</div>',
57
+ ].join('');
58
+ const picker = panel.querySelector('.comty-markup-picker');
59
+ const colorPanel = panel.querySelector('.comty-markup-color');
60
+ const colorField = panel.querySelector('.comty-markup-color-field');
61
+ const hueInput = panel.querySelector('.comty-markup-hue');
62
+ const hexInput = panel.querySelector('[aria-label="Hex color"]');
63
+ let pickerOpen = false, colorPointer = null;
64
+ let hue = 0, saturation = 1, brightness = 1;
65
+ let mode = null, shape = 'pencil', drag = null, visible = false, busy = false;
66
+ let stroke = 3, color = tokenColor('--comty-measure');
67
+ readColor(color);
68
+ for (const button of panel.querySelectorAll('[data-color]')) button.style.setProperty('--markup-swatch', tokenColor(button.dataset.color));
69
+ let revision = 0;
70
+ const history = [];
71
+ const clamp = (v, max) => Math.max(0, Math.min(max, v));
72
+ function point(event) { return { x: clamp(event.clientX, innerWidth), y: clamp(event.clientY, innerHeight) }; }
73
+ function sync() {
74
+ layer.style.display = visible ? '' : 'none';
75
+ surface.hidden = !visible || !mode;
76
+ panel.hidden = !visible || mode !== 'annotate';
77
+ for (const button of panel.querySelectorAll('[data-stroke]')) button.setAttribute('aria-pressed', String(Number(button.dataset.stroke) === stroke));
78
+ for (const button of panel.querySelectorAll('[data-color]')) button.setAttribute('aria-pressed', String(tokenColor(button.dataset.color) === color));
79
+ picker.toggleAttribute('data-selected', !palette.some(([, token]) => tokenColor(token) === color));
80
+ syncPicker();
81
+ for (const button of panel.querySelectorAll('[data-shape]')) button.setAttribute('aria-pressed', String(button.dataset.shape === shape));
82
+ panel.querySelector('[data-undo]').disabled = !history.length;
83
+ panel.querySelector('[data-clear]').disabled = !history.length;
84
+ render();
85
+ onChange();
86
+ }
87
+ function cancelDrag() {
88
+ if (!drag) return;
89
+ drag.node?.remove();
90
+ const id = drag.id;
91
+ drag = null;
92
+ if (surface.hasPointerCapture(id)) surface.releasePointerCapture(id);
93
+ selection.hidden = true;
94
+ }
95
+ function setMode(next) {
96
+ if (busy && next) return;
97
+ cancelDrag();
98
+ if (!next) revision++;
99
+ mode = next;
100
+ if (next !== 'annotate') { pickerOpen = false; finishColorDrag(); }
101
+ sync();
102
+ }
103
+ function undo() { history.pop()?.remove(); sync(); }
104
+ function clear() { cancelDrag(); history.length = 0; drawings.replaceChildren(); sync(); }
105
+ panel.querySelector('[data-undo]').onclick = undo;
106
+ panel.querySelector('[data-clear]').onclick = clear;
107
+ for (const button of panel.querySelectorAll('[data-shape]')) button.onclick = () => { shape = button.dataset.shape; sync(); };
108
+ for (const button of panel.querySelectorAll('[data-stroke]')) button.onclick = () => { stroke = Number(button.dataset.stroke); sync(); };
109
+ for (const button of panel.querySelectorAll('[data-color]')) button.onclick = () => {
110
+ color = tokenColor(button.dataset.color); readColor(color); sync();
111
+ };
112
+ function readColor(value) {
113
+ const [r, g, b] = value.slice(1).match(/../g).map(part => parseInt(part, 16) / 255);
114
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), delta = max - min;
115
+ if (delta) hue = ((max === r ? (g - b) / delta : max === g ? (b - r) / delta + 2 : (r - g) / delta + 4) * 60 + 360) % 360;
116
+ saturation = max ? delta / max : 0;
117
+ brightness = max;
118
+ }
119
+ function writeColor() {
120
+ const channel = offset => {
121
+ const k = (offset + hue / 60) % 6;
122
+ return Math.round(255 * brightness * (1 - saturation * Math.max(0, Math.min(k, 4 - k, 1)))).toString(16).padStart(2, '0');
123
+ };
124
+ color = '#' + [5, 3, 1].map(channel).join('');
125
+ sync();
126
+ }
127
+ function syncPicker() {
128
+ colorPanel.hidden = !pickerOpen;
129
+ picker.setAttribute('aria-expanded', String(pickerOpen));
130
+ colorPanel.style.setProperty('--markup-hue', `hsl(${hue} 100% 50%)`);
131
+ colorPanel.style.setProperty('--markup-color', color);
132
+ const marker = colorField.firstElementChild;
133
+ marker.style.left = `${saturation * 100}%`;
134
+ marker.style.top = `${(1 - brightness) * 100}%`;
135
+ colorField.setAttribute('aria-description', `Saturation ${Math.round(saturation * 100)}%, brightness ${Math.round(brightness * 100)}%. Left and right arrows adjust saturation. Up and down arrows adjust brightness.`);
136
+ hueInput.value = String(Math.round(hue));
137
+ hueInput.setAttribute('aria-valuetext', `${Math.round(hue)} degrees`);
138
+ if (document.activeElement !== hexInput) { hexInput.value = color.toUpperCase(); hexInput.removeAttribute('aria-invalid'); }
139
+ }
140
+ picker.onclick = () => { pickerOpen = !pickerOpen; sync(); };
141
+ function moveColor(event) {
142
+ const rect = colorField.getBoundingClientRect();
143
+ saturation = clamp((event.clientX - rect.left) / rect.width, 1);
144
+ brightness = 1 - clamp((event.clientY - rect.top) / rect.height, 1);
145
+ writeColor();
146
+ }
147
+ function finishColorDrag() {
148
+ const id = colorPointer;
149
+ colorPointer = null;
150
+ if (id !== null && colorField.hasPointerCapture(id)) colorField.releasePointerCapture(id);
151
+ }
152
+ colorField.onpointerdown = event => {
153
+ if (event.button !== 0 || colorPointer !== null) return;
154
+ event.preventDefault(); event.stopPropagation();
155
+ colorField.focus({ preventScroll: true });
156
+ colorPointer = event.pointerId;
157
+ colorField.setPointerCapture(event.pointerId);
158
+ moveColor(event);
159
+ };
160
+ colorField.onpointermove = event => { if (colorPointer === event.pointerId) moveColor(event); };
161
+ colorField.onpointerup = event => { if (colorPointer === event.pointerId) { moveColor(event); finishColorDrag(); } };
162
+ colorField.onpointercancel = finishColorDrag;
163
+ colorField.onlostpointercapture = finishColorDrag;
164
+ window.addEventListener('blur', finishColorDrag);
165
+ colorField.onkeydown = event => {
166
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
167
+ event.preventDefault(); event.stopPropagation();
168
+ const step = event.shiftKey ? .1 : .01;
169
+ if (event.key === 'ArrowLeft') saturation = clamp(saturation - step, 1);
170
+ if (event.key === 'ArrowRight') saturation = clamp(saturation + step, 1);
171
+ if (event.key === 'ArrowUp') brightness = clamp(brightness + step, 1);
172
+ if (event.key === 'ArrowDown') brightness = clamp(brightness - step, 1);
173
+ writeColor();
174
+ };
175
+ hueInput.oninput = () => { hue = Number(hueInput.value); writeColor(); };
176
+ function applyHex(commit = false) {
177
+ const value = hexInput.value.trim().replace(/^#/, '');
178
+ const valid = /^[\da-f]{6}$/i.test(value) || (commit && /^[\da-f]{3}$/i.test(value));
179
+ if (valid) {
180
+ color = '#' + (value.length === 3 ? [...value].map(char => char + char).join('') : value).toLowerCase();
181
+ readColor(color); hexInput.removeAttribute('aria-invalid'); sync();
182
+ } else hexInput.setAttribute('aria-invalid', 'true');
183
+ if (commit) { hexInput.value = color.toUpperCase(); hexInput.removeAttribute('aria-invalid'); }
184
+ }
185
+ hexInput.oninput = () => applyHex();
186
+ hexInput.onblur = () => applyHex(true);
187
+ hexInput.onkeydown = event => { if (event.key === 'Enter') { event.preventDefault(); applyHex(true); } };
188
+ function render() { drawings.setAttribute('transform', `translate(${-scrollX} ${-scrollY})`); }
189
+ function update(event) {
190
+ const p = point(event), start = drag.start;
191
+ drag.end = p;
192
+ if (mode === 'capture') {
193
+ Object.assign(selection.style, { left: `${Math.min(start.x, p.x)}px`, top: `${Math.min(start.y, p.y)}px`, width: `${Math.abs(p.x - start.x)}px`, height: `${Math.abs(p.y - start.y)}px` });
194
+ selection.textContent = `${Math.round(Math.abs(p.x - start.x))} × ${Math.round(Math.abs(p.y - start.y))}`;
195
+ selection.hidden = false;
196
+ return;
197
+ }
198
+ const a = drag.origin, b = { x: p.x + scrollX, y: p.y + scrollY };
199
+ const left = Math.min(a.x, b.x), top = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
200
+ let d;
201
+ if (shape === 'pencil') {
202
+ drag.points.push(b);
203
+ d = drag.points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ');
204
+ } else if (shape === 'rectangle') d = `M${left} ${top}h${w}v${h}h${-w}Z`;
205
+ else if (shape === 'ellipse') d = `M${left} ${top + h/2}a${w/2} ${h/2} 0 1 0 ${w} 0a${w/2} ${h/2} 0 1 0 ${-w} 0`;
206
+ else {
207
+ const angle = Math.atan2(b.y-a.y, b.x-a.x), size = Math.min(16, Math.hypot(b.x-a.x,b.y-a.y)/3);
208
+ d = `M${a.x} ${a.y}L${b.x} ${b.y}M${b.x-size*Math.cos(angle-.5)} ${b.y-size*Math.sin(angle-.5)}L${b.x} ${b.y}L${b.x-size*Math.cos(angle+.5)} ${b.y-size*Math.sin(angle+.5)}`;
209
+ }
210
+ drag.node.setAttribute('d', d);
211
+ }
212
+ surface.addEventListener('pointerdown', event => {
213
+ if (event.button !== 0 || drag || busy) return;
214
+ event.preventDefault(); event.stopPropagation();
215
+ const start = point(event), origin = { x: start.x + scrollX, y: start.y + scrollY };
216
+ drag = { id: event.pointerId, start, end: start, origin, points: [origin] };
217
+ if (mode === 'annotate') {
218
+ drag.node = document.createElementNS(ns, 'path');
219
+ drag.node.setAttribute('fill', 'none');
220
+ drag.node.setAttribute('stroke', color);
221
+ drag.node.setAttribute('stroke-width', String(stroke));
222
+ drag.node.setAttribute('stroke-linecap', 'round');
223
+ drag.node.setAttribute('stroke-linejoin', 'round');
224
+ drawings.append(drag.node);
225
+ }
226
+ surface.setPointerCapture(event.pointerId);
227
+ update(event);
228
+ });
229
+ surface.addEventListener('pointermove', event => { if (drag?.id === event.pointerId) { event.preventDefault(); update(event); } });
230
+ surface.addEventListener('pointerup', async event => {
231
+ if (drag?.id !== event.pointerId) return;
232
+ event.preventDefault(); event.stopPropagation();
233
+ update(event);
234
+ const finished = drag;
235
+ drag = null;
236
+ surface.releasePointerCapture(event.pointerId);
237
+ selection.hidden = true;
238
+ if (mode === 'annotate') { history.push(finished.node); sync(); return; }
239
+ const region = { left: Math.floor(Math.min(finished.start.x, finished.end.x)), top: Math.floor(Math.min(finished.start.y, finished.end.y)), width: Math.round(Math.abs(finished.end.x-finished.start.x)), height: Math.round(Math.abs(finished.end.y-finished.start.y)) };
240
+ if (region.width < 4 || region.height < 4) return;
241
+ busy = true;
242
+ const current = ++revision;
243
+ surface.setAttribute('aria-busy', 'true');
244
+ sync();
245
+ try {
246
+ const result = await capture(region, layer);
247
+ if (current !== revision) return;
248
+ if (!result) throw new Error('Page is not visible');
249
+ const url = URL.createObjectURL(result.blob);
250
+ const link = document.createElement('a');
251
+ link.href = url; link.download = `comty-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
252
+ link.click();
253
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
254
+ } catch (error) {
255
+ if (current === revision) onError(`Capture failed. ${error.message}. Try again.`);
256
+ } finally {
257
+ busy = false; surface.removeAttribute('aria-busy'); sync();
258
+ }
259
+ });
260
+ surface.addEventListener('pointercancel', cancelDrag);
261
+ surface.addEventListener('lostpointercapture', cancelDrag);
262
+ surface.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); });
263
+ surface.addEventListener('wheel', event => { if (drag || busy) event.preventDefault(); }, { passive: false });
264
+ document.addEventListener('scroll', () => { cancelDrag(); render(); }, { passive: true, capture: true });
265
+ window.addEventListener('resize', cancelDrag);
266
+ window.addEventListener('blur', cancelDrag);
267
+ return {
268
+ panel, setMode, render, clear,
269
+ get mode() { return mode; },
270
+ get busy() { return busy; },
271
+ setVisible(value) { visible = value; if (!value) { revision++; mode = null; pickerOpen = false; finishColorDrag(); cancelDrag(); } sync(); },
272
+ keydown(event) {
273
+ if (!mode) return false;
274
+ if (event.key === 'Escape') {
275
+ if (pickerOpen) { pickerOpen = false; finishColorDrag(); picker.focus({ preventScroll: true }); sync(); return true; }
276
+ if (drag) cancelDrag(); else setMode(null);
277
+ return true;
278
+ }
279
+ if (mode === 'annotate' && (event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === 'z') { cancelDrag(); undo(); return true; }
280
+ return false;
281
+ },
282
+ };
283
+ }
@@ -30,7 +30,7 @@ export function measurementPoint(anchor, rect, scroll = { x: 0, y: 0 }) {
30
30
  }
31
31
 
32
32
  /** Border measurements live for one activation of the existing ruler tool. */
33
- export function createMeasurements({ root, isComtyElement, isElementTreeHidden, onExit }) {
33
+ export function createMeasurements({ root, isComtyElement, isElementTreeHidden, onExit, isInteractionBlocked = () => false }) {
34
34
  let enabled = false;
35
35
  let hover = null;
36
36
  let drag = null;
@@ -154,7 +154,7 @@ export function createMeasurements({ root, isComtyElement, isElementTreeHidden,
154
154
  }
155
155
  }
156
156
  function isHostEvent(event) {
157
- if (!enabled || !(event.target instanceof Element)) return false;
157
+ if (!enabled || isInteractionBlocked() || !(event.target instanceof Element)) return false;
158
158
  if (!isComtyElement(event.target)) return true;
159
159
  // The outer ruler strip must still let us anchor to the page's top/left edge.
160
160
  return event.target.matches(".comty-ruler") && (event.clientX + scrollX <= 10 || event.clientY + scrollY <= 10);
@@ -192,7 +192,7 @@ export function createMeasurements({ root, isComtyElement, isElementTreeHidden,
192
192
  if (isHostEvent(event)) { event.preventDefault(); event.stopImmediatePropagation(); }
193
193
  }, true);
194
194
  document.addEventListener("keydown", event => {
195
- if (!enabled || event.key !== "Escape" || event.target.closest?.(".comty-auth-dialog, input, textarea, [contenteditable=true]")) return;
195
+ if (!enabled || isInteractionBlocked() || event.key !== "Escape" || event.target.closest?.(".comty-auth-dialog, input, textarea, [contenteditable=true]")) return;
196
196
  event.preventDefault(); event.stopImmediatePropagation();
197
197
  if (drag) cancel(); else onExit();
198
198
  }, true);