@pointfix/web 0.0.0-stage → 1.0.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/LICENSE.md ADDED
@@ -0,0 +1,3 @@
1
+ © 2026 Vincent Villalta. All rights reserved.
2
+
3
+ You may use this package in your own projects, free of charge. Redistribution or modification of the package outside your projects is not permitted.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @pointfix/web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Pointfix reporter for web apps. Point at an element on your running page, describe the change, and your local coding agent fixes it. Pointfix rebuilds or reloads the page and you review the result before anything is committed.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -D @pointfix/web
9
+ ```
10
+
11
+ Load it in development only:
12
+
13
+ ```js
14
+ if (import.meta.env.DEV) {
15
+ const { mount } = await import('@pointfix/web');
16
+ mount(); // uses the bridge at http://127.0.0.1:4747
17
+ }
18
+ ```
19
+
20
+ `mount({ bridge })` points at another bridge address. It returns `{ unmount() }`.
21
+
22
+ You also need the Pointfix bridge running in your repository (`brew install pointfix-dev/tap/pointfix`, then `pointfix start`), and your dev server's origin listed in `allowedOrigins` in `pointfix.config.json`.
23
+
24
+ ## Use
25
+
26
+ Click **Report UI issue**, then an element, or hold **Alt + Shift** and click. Describe the change and send.
27
+
28
+ Docs: https://pointfix.dev/docs/web
package/browser.js ADDED
@@ -0,0 +1,251 @@
1
+ // Pointfix web reporter: pick an element, describe the change, follow the fix. Development only.
2
+ // One implementation, two entry points: a <script src=".../sdk/browser.js"> tag (bridge = the script's origin)
3
+ // and the @pointfix/web ES module (index.mjs imports this file; bridge defaults to the local bridge).
4
+ (() => {
5
+ if (typeof window === 'undefined' || window.Pointfix) return; // server-side render, or already loaded
6
+ const scriptBase = document.currentScript?.src ? new URL(document.currentScript.src).origin : 'http://127.0.0.1:4747';
7
+ const CATEGORIES = ['spacing', 'color', 'text', 'size', 'layout', 'other'];
8
+ const WORKING = ['sent', 'queued', 'fixing', 'checking', 'rebuilding'], DONE = ['verified', 'committed', 'resolved', 'reverted'], FAILED = ['failed', 'interrupted', 'stopped', 'offline'];
9
+ const TITLES = { sent: 'Sending', queued: 'Queued', fixing: 'Agent working', checking: 'Checking', rebuilding: 'Relaunching', review: 'Ready to review', verified: 'Reviewed', committed: 'Committed', resolved: 'Resolved', reverted: 'Reverted', offline: 'Bridge unreachable', interrupted: 'Interrupted', stopped: 'Stopped', failed: 'Failed' };
10
+ const store = { get: (k, s = sessionStorage) => { try { return s.getItem(k); } catch { return null; } }, set: (k, v, s = sessionStorage) => { try { s.setItem(k, v); } catch {} } };
11
+
12
+ const CSS_TEXT = `
13
+ :host { all: initial; }
14
+ * { box-sizing: border-box; }
15
+ .root { --surface: #fff; --field: #f1f1ec; --chip: #ecece6; --ink: #1e211d; --on-ink: #fff; --muted: #6f746b; --line: #e1e1da; --brand: #d95b36; --review: #b7770f; --done: #2d7550; --fail: #bb3a2a; --code: #1f2420; --code-ink: #d7dfd1;
16
+ font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); }
17
+ @media (prefers-color-scheme: dark) { .root { --surface: #232622; --field: #1a1c19; --chip: #2e322c; --ink: #eceee8; --on-ink: #1e211d; --muted: #9da397; --line: #3a3f37; --review: #e3b35f; --done: #8fcfa8; --fail: #f0998a; } }
18
+ button, textarea { font: inherit; color: inherit; }
19
+ button { cursor: pointer; border: 0; background: none; padding: 0; }
20
+ button:focus-visible, textarea:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
21
+ [hidden] { display: none !important; }
22
+ .layer { position: fixed; z-index: 2147483646; pointer-events: none; }
23
+ .hover { border: 2px solid var(--brand); border-radius: 6px; background: color-mix(in srgb, var(--brand) 8%, transparent); transition: all .06s; }
24
+ .hover span { position: absolute; left: -2px; top: -24px; max-width: 320px; padding: 2px 7px; border-radius: 5px; background: var(--brand); color: #fff; font: 600 11px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
25
+ .spot { border: 2px solid var(--brand); border-radius: 10px; box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .45); }
26
+ .launcher { position: fixed; right: 20px; bottom: 20px; z-index: 2147483647; display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px 0 10px; border-radius: 18px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0,0,0,.14); font-weight: 600; }
27
+ .launcher:hover { box-shadow: 0 8px 28px rgba(0,0,0,.2); }
28
+ .launcher.picking { background: var(--ink); color: var(--on-ink); border-color: var(--ink); }
29
+ .launcher kbd { font: 10px ui-monospace, Menlo, monospace; color: var(--muted); }
30
+ .launcher.picking kbd { color: inherit; opacity: .7; }
31
+ .mark { width: 20px; height: 20px; flex-shrink: 0; }
32
+ .card { position: fixed; z-index: 2147483647; right: 20px; bottom: 20px; width: 400px; max-width: calc(100vw - 24px); padding: 14px; border-radius: 20px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 16px 48px rgba(0,0,0,.28); display: grid; gap: 12px; }
33
+ .head { display: flex; align-items: center; gap: 10px; }
34
+ .icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: var(--brand); background: color-mix(in srgb, var(--brand) 13%, transparent); font-weight: 700; flex-shrink: 0; }
35
+ .names { flex: 1; min-width: 0; }
36
+ .title { font-weight: 650; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
37
+ .title.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
38
+ .sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
39
+ .round { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--chip); color: var(--muted); font-size: 12px; font-weight: 700; flex-shrink: 0; }
40
+ .round:hover { color: var(--ink); }
41
+ .chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
42
+ .chips button { padding: 4px 11px; border-radius: 14px; background: var(--chip); font-size: 12.5px; white-space: nowrap; }
43
+ .chips button[aria-pressed="true"] { background: var(--ink); color: var(--on-ink); font-weight: 600; }
44
+ .packet { display: grid; gap: 4px; }
45
+ .packet pre { margin: 0; max-height: 170px; overflow: auto; padding: 10px; border-radius: 12px; background: var(--code); color: var(--code-ink); font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre; }
46
+ .packet small, .note { font-size: 11px; color: var(--muted); }
47
+ textarea { width: 100%; min-height: 64px; max-height: 180px; resize: vertical; padding: 10px 12px; border: 0; border-radius: 12px; background: var(--field); font-size: 14px; line-height: 1.45; }
48
+ textarea::placeholder { color: var(--muted); }
49
+ .foot { display: flex; align-items: center; gap: 10px; }
50
+ .context { font-size: 12.5px; font-weight: 600; color: var(--muted); }
51
+ .context[aria-pressed="true"] { color: var(--brand); }
52
+ .send { margin-left: auto; display: flex; align-items: center; gap: 6px; height: 34px; padding: 0 16px; border-radius: 17px; background: var(--ink); color: var(--on-ink); font-weight: 650; }
53
+ .send:disabled { background: var(--chip); color: var(--muted); cursor: default; }
54
+ .send kbd { font: 10px ui-monospace, Menlo, monospace; opacity: .65; }
55
+ .pill { position: fixed; z-index: 2147483647; top: 16px; right: 16px; width: 340px; max-width: calc(100vw - 24px); padding: 12px; border-radius: 16px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 10px 36px rgba(0,0,0,.18); display: grid; gap: 10px; }
56
+ .row { display: flex; align-items: flex-start; gap: 10px; }
57
+ .state { width: 16px; height: 16px; margin-top: 2px; flex-shrink: 0; display: grid; place-items: center; font-size: 13px; font-weight: 700; }
58
+ .spin { border-radius: 50%; border: 2px solid var(--line); border-top-color: var(--ink); animation: spin .9s linear infinite; }
59
+ .dot::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--review); }
60
+ @keyframes spin { to { transform: rotate(360deg); } }
61
+ .status-title { font-weight: 650; }
62
+ .elapsed { margin-left: 6px; font: 11px ui-monospace, Menlo, monospace; color: var(--muted); }
63
+ .detail { font-size: 12px; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
64
+ .steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
65
+ .steps i { height: 3px; border-radius: 2px; background: var(--chip); }
66
+ .steps i.past { background: var(--ink); } .steps i.now { background: var(--brand); }
67
+ .steps span { font-size: 10.5px; color: var(--muted); } .steps span.now { color: var(--ink); font-weight: 600; }
68
+ .actions a { display: block; text-align: center; padding: 7px; border-radius: 10px; background: var(--ink); color: var(--on-ink); font-weight: 650; text-decoration: none; }
69
+ @media (max-width: 520px) { .card { right: 12px; left: 12px; bottom: 12px; width: auto; } .pill { left: 12px; right: 12px; width: auto; } }
70
+ @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }`;
71
+
72
+
73
+ window.Pointfix = { mount({ bridge = scriptBase } = {}) { // the agent is a bridge setting; a provider option is ignored
74
+ if (document.querySelector('[data-pointfix-host]')) throw new Error('Pointfix is already mounted');
75
+ const host = document.createElement('div'); host.dataset.pointfixHost = ''; document.body.append(host);
76
+ const shadow = host.attachShadow({ mode: 'open' });
77
+ shadow.innerHTML = `<style>${CSS_TEXT}</style><div class="root">
78
+ <div class="layer hover" hidden><span></span></div>
79
+ <div class="layer spot" hidden></div>
80
+ <button class="launcher" title="Report a UI issue (Alt + Shift + click an element)"><img class="mark" src="${bridge}/mark.png" alt=""><span class="launch-label">Report UI issue</span><kbd>⌥⇧ click</kbd></button>
81
+ <section class="card" role="dialog" aria-label="Report a UI issue" hidden>
82
+ <div class="head"><span class="icon" aria-hidden="true">◎</span><div class="names"><div class="title"></div><div class="sub"></div></div><button class="round close" aria-label="Cancel">✕</button></div>
83
+ <div class="chips" role="group" aria-label="Change category, optional">${CATEGORIES.map(c => `<button data-category="${c}" aria-pressed="false">${c[0].toUpperCase()}${c.slice(1)}</button>`).join('')}</div>
84
+ <div class="packet" hidden><pre aria-label="Context packet, read only">Loading context packet…</pre><small>Read only. On send, the bridge adds the screenshot.</small></div>
85
+ <textarea aria-label="Describe the issue" placeholder="What should change?"></textarea>
86
+ <p class="note" role="status" hidden></p>
87
+ <div class="foot"><button class="context" aria-pressed="false">{ } Context</button><button class="send" disabled>Send <kbd>⌘↵</kbd></button></div>
88
+ </section>
89
+ <section class="pill" role="status" hidden>
90
+ <div class="row"><span class="state"></span><div style="flex:1;min-width:0"><div><span class="status-title"></span><span class="elapsed"></span></div><div class="detail"></div></div><button class="round dismiss" aria-label="Dismiss" style="width:24px;height:24px;font-size:11px">✕</button></div>
91
+ <div class="steps" aria-hidden="true"><i></i><i></i><i></i><i></i><span>Sent</span><span>Agent</span><span>Relaunch</span><span>Review</span></div>
92
+ <div class="actions" hidden><a target="_blank" rel="noopener">Open in Pointfix</a></div>
93
+ </section></div>`;
94
+ const $ = selector => shadow.querySelector(selector);
95
+ const textarea = $('textarea'), sendButton = $('.send');
96
+ let picking = false, selected, category = null, lastId = store.get('pointfix-report'), timer, ticker, previewTimer, spotFrame;
97
+ let expert = store.get('pointfix-expert', localStorage) === '1';
98
+
99
+ const selectorFor = el => { if (el.id) return `#${CSS.escape(el.id)}`; if (el.hasAttribute('data-testid')) return `[data-testid=${JSON.stringify(el.getAttribute('data-testid'))}]`; const parts = []; for (let node = el; node && node !== document.body && parts.length < 5; node = node.parentElement) { let part = node.tagName.toLowerCase(); if (node.parentElement) part += `:nth-child(${Array.from(node.parentElement.children).indexOf(node) + 1})`; parts.unshift(part); } return parts.join(' > '); };
100
+ const targetOf = node => node.closest('[id], [data-testid], [data-pointfix-name], button, a, input, select, textarea, [role]') || node;
101
+ const identifierOf = el => el.id || el.getAttribute('data-testid') || undefined;
102
+ const nameOf = el => el.getAttribute('data-pointfix-name') || identifierOf(el) || el.getAttribute('aria-label') || el.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) || selectorFor(el);
103
+ const place = (layer, rect, pad = 0) => Object.assign(layer.style, { left: `${rect.left - pad}px`, top: `${rect.top - pad}px`, width: `${rect.width + pad * 2}px`, height: `${rect.height + pad * 2}px` });
104
+
105
+ const call = async (path, body) => {
106
+ const response = await fetch(`${bridge}${path}`, { method: body ? 'POST' : 'GET', headers: body ? { 'Content-Type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
107
+ const result = await response.json(); if (!response.ok) throw new Error(result.error); return result;
108
+ };
109
+ async function screenshot() {
110
+ if (!window.html2canvas) await new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = `${bridge}/sdk/html2canvas.js`; script.onload = resolve; script.onerror = () => reject(new Error('Screenshot library failed to load')); document.head.append(script); });
111
+ const canvas = await window.html2canvas(document.body, { useCORS: true, logging: false, scale: Math.min(devicePixelRatio, 2), x: window.scrollX, y: window.scrollY, width: innerWidth, height: innerHeight, ignoreElements: el => el === host });
112
+ return canvas.toDataURL('image/png').split(',')[1];
113
+ }
114
+ function report(comment) {
115
+ const style = getComputedStyle(selected), frame = selected.getBoundingClientRect();
116
+ return { comment, screen: document.title, category: category || undefined,
117
+ element: { name: nameOf(selected), identifier: identifierOf(selected), accessibilityLabel: selected.getAttribute('aria-label') || (selected.getAttribute('aria-labelledby') || '').split(/\s+/).map(id => document.getElementById(id)?.textContent || '').join(' ').trim() || undefined, type: selected.tagName.toLowerCase(), resolution: 'dom', file: selected.getAttribute('data-pointfix-file') || undefined, line: Number(selected.getAttribute('data-pointfix-line')) || undefined, frame: { x: frame.x, y: frame.y, width: frame.width, height: frame.height } },
118
+ browser: { url: location.origin + location.pathname, selector: selectorFor(selected), text: selected.textContent?.slice(0, 2000), role: selected.getAttribute('role'), styles: { color: style.color, background: style.backgroundColor, font: style.font, padding: style.padding, borderRadius: style.borderRadius }, viewport: { width: innerWidth, height: innerHeight } } };
119
+ }
120
+
121
+ // Picking: a labelled outline follows the cursor so you see what you'll select.
122
+ function setPicking(on) {
123
+ picking = on; $('.launcher').classList.toggle('picking', on);
124
+ $('.launch-label').textContent = on ? 'Click an element' : 'Report UI issue';
125
+ $('.launcher kbd').textContent = on ? 'Esc' : '⌥⇧ click';
126
+ if (!on) $('.hover').hidden = true;
127
+ }
128
+ const move = event => {
129
+ if (!(picking || (event.altKey && event.shiftKey)) || event.composedPath().includes(host)) { if (!picking) $('.hover').hidden = true; return; }
130
+ const el = targetOf(event.target); place($('.hover'), el.getBoundingClientRect(), 2);
131
+ $('.hover span').textContent = nameOf(el); $('.hover').hidden = false;
132
+ };
133
+ const click = event => {
134
+ if (event.composedPath().includes(host) || !(picking || (event.altKey && event.shiftKey))) return;
135
+ event.preventDefault(); event.stopImmediatePropagation();
136
+ setPicking(false); open(targetOf(event.target));
137
+ };
138
+
139
+ // Composer.
140
+ function trackSpot() { if (!selected || $('.card').hidden) return; place($('.spot'), selected.getBoundingClientRect(), 4); spotFrame = requestAnimationFrame(trackSpot); }
141
+ function open(el) {
142
+ selected = el; category = null; textarea.value = ''; $('.note').hidden = true;
143
+ const identifier = identifierOf(el), file = el.getAttribute('data-pointfix-file'), line = el.getAttribute('data-pointfix-line');
144
+ $('.title').textContent = identifier || nameOf(el); $('.title').classList.toggle('mono', Boolean(identifier));
145
+ $('.sub').textContent = [document.title, 'Web', file && `${file.split('/').pop()}${line ? `:${line}` : ''}`].filter(Boolean).join(' · ');
146
+ shadow.querySelectorAll('[data-category]').forEach(b => b.setAttribute('aria-pressed', 'false'));
147
+ $('.launcher').hidden = true; $('.pill').hidden = true; $('.spot').hidden = false; $('.card').hidden = false;
148
+ paintExpert(); refreshSend(); trackSpot(); schedulePreview(true);
149
+ textarea.focus();
150
+ }
151
+ function close() {
152
+ cancelAnimationFrame(spotFrame); $('.card').hidden = true; $('.spot').hidden = true; $('.launcher').hidden = false;
153
+ setPicking(false); selected = null; if (lastId && !$('.pill').dataset.dismissed) $('.pill').hidden = !$('.pill').dataset.status;
154
+ }
155
+ const refreshSend = () => { sendButton.disabled = !textarea.value.trim(); };
156
+ function paintExpert() { $('.packet').hidden = !expert; $('.context').setAttribute('aria-pressed', String(expert)); $('.context').setAttribute('aria-label', expert ? 'Hide context packet' : 'Show context packet'); }
157
+ // The packet the bridge would build, same normalization as a real report; nothing is stored.
158
+ function schedulePreview(now = false) {
159
+ if (!expert || !selected) return;
160
+ clearTimeout(previewTimer);
161
+ previewTimer = setTimeout(async () => {
162
+ try { $('.packet pre').textContent = JSON.stringify(await call('/packet', report(textarea.value)), null, 2); }
163
+ catch (error) { $('.packet pre').textContent = `Preview unavailable: ${error.message}`; }
164
+ }, now ? 0 : 400);
165
+ }
166
+ async function submit() {
167
+ const comment = textarea.value.trim(); if (!comment || !selected) return;
168
+ sendButton.disabled = true;
169
+ const body = report(comment);
170
+ let screenshotPNG;
171
+ $('.spot').hidden = true; $('.card').hidden = true; // keep Pointfix out of the screenshot
172
+ try { screenshotPNG = await screenshot(); } catch {}
173
+ try {
174
+ const result = await call('/report', { ...body, screenshotPNG });
175
+ lastId = result.id; store.set('pointfix-report', lastId); store.set('pointfix-path', location.pathname);
176
+ store.set('pointfix-request', comment); store.set('pointfix-sent', String(Date.now()));
177
+ close(); delete $('.pill').dataset.dismissed; showStatus(result.status); track();
178
+ } catch (error) {
179
+ $('.card').hidden = false; $('.spot').hidden = false;
180
+ $('.note').textContent = `Couldn’t send: ${error.message}`; $('.note').hidden = false; refreshSend();
181
+ }
182
+ }
183
+
184
+ // Progress pill.
185
+ function showStatus(status, note) {
186
+ const pill = $('.pill'), changed = pill.dataset.status !== status;
187
+ pill.dataset.status = status;
188
+ if (changed) delete pill.dataset.dismissed;
189
+ if (pill.dataset.dismissed || !$('.card').hidden) return;
190
+ const working = WORKING.includes(status), done = DONE.includes(status), failed = FAILED.includes(status);
191
+ const state = $('.state'); state.className = `state ${working ? 'spin' : status === 'review' ? 'dot' : ''}`;
192
+ state.textContent = done ? '✓' : failed ? '✕' : ''; state.style.color = done ? 'var(--done)' : 'var(--fail)';
193
+ $('.status-title').textContent = TITLES[status] || 'Failed';
194
+ $('.detail').textContent = note || store.get('pointfix-request') || '';
195
+ const step = { sent: 0, queued: 0, fixing: 1, checking: 1, rebuilding: 2 }[status] ?? 3;
196
+ shadow.querySelectorAll('.steps i').forEach((bar, i) => { bar.className = i < step || done ? 'past' : i === step ? 'now' : ''; });
197
+ shadow.querySelectorAll('.steps span').forEach((label, i) => label.classList.toggle('now', i === step && !done));
198
+ $('.steps').hidden = failed;
199
+ $('.actions').hidden = status !== 'review';
200
+ $('.actions a').href = `${bridge}/#inbox`;
201
+ clearInterval(ticker);
202
+ const tick = () => { const sent = Number(store.get('pointfix-sent')); $('.elapsed').textContent = working && sent ? `${Math.round((Date.now() - sent) / 1000)}s` : ''; };
203
+ tick(); if (working) ticker = setInterval(tick, 1000);
204
+ pill.setAttribute('aria-label', `${TITLES[status] || 'Failed'}. ${$('.detail').textContent}`);
205
+ pill.hidden = false;
206
+ if (done) setTimeout(() => { if (pill.dataset.status === status) pill.hidden = true; }, 5000);
207
+ }
208
+ // Auto-relaunch on the web: reload once when the fix is ready, then capture the proposed screen.
209
+ function track() {
210
+ clearInterval(timer);
211
+ let failures = 0;
212
+ timer = setInterval(async () => {
213
+ try {
214
+ const result = await call(`/status?id=${lastId}`); failures = 0;
215
+ if (result.status === 'review' && result.reload && store.get('pointfix-reloaded') !== lastId) { store.set('pointfix-reloaded', lastId); location.reload(); return; }
216
+ showStatus(result.status);
217
+ if (result.status === 'review' && store.get('pointfix-after') !== lastId && store.get('pointfix-path') === location.pathname) {
218
+ store.set('pointfix-after', lastId);
219
+ await new Promise(resolve => setTimeout(resolve, 800)); // let the page settle after a reload
220
+ const pillHidden = $('.pill').hidden; $('.pill').hidden = true;
221
+ try { await call('/after', { id: lastId, screenshotPNG: await screenshot() }); showStatus('review', 'Proposed screen sent. Compare it in Pointfix.'); }
222
+ catch { $('.pill').hidden = pillHidden; }
223
+ }
224
+ if ([...DONE, 'failed', 'interrupted', 'stopped'].includes(result.status)) clearInterval(timer);
225
+ } catch { if (++failures >= 3) { clearInterval(timer); showStatus('offline', 'Can’t reach Pointfix. Check that the bridge is running.'); } }
226
+ }, 2500);
227
+ }
228
+
229
+ // Wiring.
230
+ $('.launcher').onclick = () => setPicking(!picking);
231
+ $('.close').onclick = close;
232
+ $('.dismiss').onclick = () => { $('.pill').dataset.dismissed = '1'; $('.pill').hidden = true; };
233
+ $('.context').onclick = () => { expert = !expert; store.set('pointfix-expert', expert ? '1' : '0', localStorage); paintExpert(); schedulePreview(true); };
234
+ shadow.querySelectorAll('[data-category]').forEach(button => button.onclick = () => {
235
+ category = category === button.dataset.category ? null : button.dataset.category;
236
+ shadow.querySelectorAll('[data-category]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.category === category)));
237
+ schedulePreview(true);
238
+ });
239
+ textarea.oninput = () => { refreshSend(); schedulePreview(); };
240
+ textarea.onkeydown = event => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); submit(); } };
241
+ sendButton.onclick = submit;
242
+ const keydown = event => { if (event.key !== 'Escape') return; if (!$('.card').hidden) close(); else if (picking) setPicking(false); };
243
+ document.addEventListener('click', click, true); document.addEventListener('mousemove', move, true); document.addEventListener('keydown', keydown, true);
244
+ if (lastId) { call('/launched', { id: lastId }).catch(() => {}); track(); }
245
+ return { unmount() {
246
+ clearInterval(timer); clearInterval(ticker); clearTimeout(previewTimer); cancelAnimationFrame(spotFrame);
247
+ document.removeEventListener('click', click, true); document.removeEventListener('mousemove', move, true); document.removeEventListener('keydown', keydown, true);
248
+ host.remove();
249
+ } };
250
+ } };
251
+ })();
package/index.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export interface MountOptions {
2
+ /** Address of the local Pointfix bridge. Default: http://127.0.0.1:4747 */
3
+ bridge?: string;
4
+ }
5
+
6
+ export interface Reporter {
7
+ /** Removes the reporter from the page. */
8
+ unmount(): void;
9
+ }
10
+
11
+ /** Adds the Pointfix reporter to the page. Load it in development builds only. */
12
+ export function mount(options?: MountOptions): Reporter;
package/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ // @pointfix/web: the Pointfix web reporter as an ES module. Development only.
2
+ // import { mount } from '@pointfix/web';
3
+ // const reporter = mount(); // bridge defaults to http://127.0.0.1:4747
4
+ // reporter.unmount();
5
+ import './browser.js'; // ponytail: shares the script-tag implementation via window.Pointfix, no build step
6
+
7
+ /** Mounts the reporter. Options: { bridge } (default http://127.0.0.1:4747). Returns { unmount() }. */
8
+ export function mount(options) {
9
+ if (typeof window === 'undefined') return { unmount() {} }; // server-side render: nothing to mount
10
+ return window.Pointfix.mount(options);
11
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@pointfix/web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Pointfix web reporter: point at a UI problem in your running web app and send it to your local coding agent.",
5
+ "type": "module",
6
+ "private": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "default": "./index.mjs"
11
+ }
12
+ },
13
+ "files": [
14
+ "index.mjs",
15
+ "index.d.ts",
16
+ "browser.js",
17
+ "README.md",
18
+ "LICENSE.md"
19
+ ],
20
+ "homepage": "https://pointfix.dev",
21
+ "license": "SEE LICENSE IN LICENSE.md",
22
+ "author": "Vincent Villalta",
23
+ "types": "./index.d.ts",
24
+ "sideEffects": [
25
+ "./browser.js"
26
+ ],
27
+ "keywords": [
28
+ "pointfix",
29
+ "ui",
30
+ "feedback",
31
+ "developer-tools",
32
+ "coding-agent",
33
+ "devtools"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }