@leonidstern/labfb 0.0.0-stage → 0.4.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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/api/lab-comments.mjs +359 -0
- package/lib/dev.mjs +66 -0
- package/local.mjs +733 -0
- package/package.json +43 -4
- package/public/_lab/comments.js +1328 -0
- package/public/_lab/fonts/OFL.txt +92 -0
- package/public/_lab/fonts/inter-latin-400.woff2 +0 -0
- package/public/_lab/fonts/inter-latin-500.woff2 +0 -0
- package/skills/labfb/SKILL.md +28 -0
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* lab comments: anyone with the link clicks anywhere on a lab page to pin a comment. No login, just a name.
|
|
3
|
+
* https://github.com/leonidstern/labfb · MIT
|
|
4
|
+
* Comments are off by default; the bar button or c turns them on (remembered per browser). When on, click anywhere that isn't a link or control to pin one (alt/option-click pins on those too). c (or the bar button) hides
|
|
5
|
+
* and shows them; esc or a click outside closes an open comment. Dragging (mouse/pen) marks an area instead of a point.
|
|
6
|
+
* Include on a page served through your lab domain:
|
|
7
|
+
* <script src="https://lab.example.com/_lab/comments.js" defer></script>
|
|
8
|
+
* The layer only turns on when the page is opened on the host the script is loaded from (or data-host),
|
|
9
|
+
* so the deployment's own address and local dev stay clean. data-enable="always" turns it on everywhere.
|
|
10
|
+
* Admin (resolve / delete / copy agent link): open any page once with ?lab-admin=<LAB_COMMENTS_ADMIN_TOKEN>; it is kept in this browser.
|
|
11
|
+
*/
|
|
12
|
+
(() => {
|
|
13
|
+
const script = document.currentScript;
|
|
14
|
+
const labHost = script?.dataset.host || (script?.src ? new URL(script.src, location.href).hostname : location.hostname);
|
|
15
|
+
const enabled = location.hostname === labHost || script?.dataset.enable === 'always';
|
|
16
|
+
if (!enabled || window.__labComments) return;
|
|
17
|
+
window.__labComments = true;
|
|
18
|
+
|
|
19
|
+
const API = script?.dataset.api || '/_lab/api/comments';
|
|
20
|
+
const pageNow = () => script?.dataset.page || location.pathname.replace(/\/+$/, '') || '/';
|
|
21
|
+
let page = pageNow();
|
|
22
|
+
const store = {
|
|
23
|
+
get: (k) => {
|
|
24
|
+
try {
|
|
25
|
+
return localStorage.getItem(k) || '';
|
|
26
|
+
} catch {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
set: (k, v) => {
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem(k, v);
|
|
33
|
+
} catch {}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Admin keys are kept per lab (name.scope.sig), so one browser can hold links for /anna and /mira at once.
|
|
38
|
+
// The owner token has no scope and works everywhere.
|
|
39
|
+
const scopeOf = (key) => (key.split('.').length === 3 ? key.split('.')[1] : '');
|
|
40
|
+
// Admin links carry the key after # (never sent to the server, so it stays out of the member site's logs); ?lab-admin= still works.
|
|
41
|
+
const adminParam = new URLSearchParams(location.hash.slice(1)).get('lab-admin') || new URLSearchParams(location.search).get('lab-admin');
|
|
42
|
+
if (adminParam) {
|
|
43
|
+
const scope = scopeOf(adminParam);
|
|
44
|
+
store.set(scope ? `lab-admin:${scope}` : 'lab-admin', adminParam);
|
|
45
|
+
const url = new URL(location.href);
|
|
46
|
+
url.searchParams.delete('lab-admin');
|
|
47
|
+
if (/lab-admin=/.test(url.hash)) url.hash = '';
|
|
48
|
+
history.replaceState(null, '', url);
|
|
49
|
+
}
|
|
50
|
+
const adminToken = script?.dataset.admin || store.get(`lab-admin:${page.split('/')[1] || ''}`) || store.get('lab-admin:*') || store.get('lab-admin');
|
|
51
|
+
|
|
52
|
+
let comments = [];
|
|
53
|
+
let isAdmin = false;
|
|
54
|
+
// Off by default: the page looks clean until someone turns comments on (button or c); remembered per browser.
|
|
55
|
+
let on = store.get('lab-on') ? store.get('lab-on') === '1' : script?.dataset.on === '1';
|
|
56
|
+
let openId = null;
|
|
57
|
+
let draft = null; // { anchor, docX, docY }
|
|
58
|
+
|
|
59
|
+
// ---------- DOM ----------
|
|
60
|
+
const host = document.createElement('div');
|
|
61
|
+
host.setAttribute('data-lab-comments', '');
|
|
62
|
+
host.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483000;';
|
|
63
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
64
|
+
root.innerHTML = `
|
|
65
|
+
<style>
|
|
66
|
+
/* Theme: set --lab-accent, --lab-accent-fg, --lab-bg, --lab-fg, --lab-radius, --lab-font or --lab-font-features on the
|
|
67
|
+
[data-lab-comments] element (or on :root). Everything else is mixed from those. */
|
|
68
|
+
:host { all: initial;
|
|
69
|
+
--_bg: var(--lab-bg, #fff); --_fg: var(--lab-fg, #141414);
|
|
70
|
+
--_accent: var(--lab-accent, #141414); --_accent-fg: var(--lab-accent-fg, #fff);
|
|
71
|
+
--_radius: var(--lab-radius, 12px);
|
|
72
|
+
--_muted: color-mix(in srgb, var(--_fg) 60%, var(--_bg));
|
|
73
|
+
--_line: color-mix(in srgb, var(--_fg) 13%, var(--_bg));
|
|
74
|
+
--_soft: color-mix(in srgb, var(--_fg) 6%, var(--_bg));
|
|
75
|
+
--_hair: .5px solid color-mix(in srgb, var(--_fg) 16%, transparent);
|
|
76
|
+
--_shadow: 0 0 0 .5px color-mix(in srgb, var(--_fg) 14%, transparent), 0 1px 2px rgba(0,0,0,.04), 0 8px 28px rgba(0,0,0,.08);
|
|
77
|
+
--_ease: cubic-bezier(.2, .8, .2, 1); }
|
|
78
|
+
@media (prefers-color-scheme: dark) {
|
|
79
|
+
:host { --_bg: var(--lab-bg, #1c1c1e); --_fg: var(--lab-fg, #f4f4f5);
|
|
80
|
+
--_accent: var(--lab-accent, #f4f4f5); --_accent-fg: var(--lab-accent-fg, #141414);
|
|
81
|
+
--_shadow: 0 0 0 1px color-mix(in srgb, var(--_fg) 16%, transparent), 0 10px 32px rgba(0,0,0,.5); }
|
|
82
|
+
}
|
|
83
|
+
* { box-sizing: border-box; font-family: var(--lab-font, 'lab-inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
|
|
84
|
+
font-size: 14px; line-height: 1.4; font-weight: 400; color: var(--_fg); letter-spacing: 0;
|
|
85
|
+
font-feature-settings: var(--lab-font-features, 'calt', 'ss03', 'cv05'); font-synthesis: none; font-optical-sizing: auto;
|
|
86
|
+
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility;
|
|
87
|
+
caret-color: var(--_accent); -webkit-tap-highlight-color: transparent; }
|
|
88
|
+
::selection { background: color-mix(in srgb, var(--_accent) 38%, transparent); color: var(--_fg); }
|
|
89
|
+
button:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; }
|
|
90
|
+
.layer { position: absolute; top: 0; left: 0; width: 0; height: 0; }
|
|
91
|
+
.pin { position: absolute; width: 28px; height: 28px; margin: -28px 0 0 0; border-radius: 14px 14px 14px 2px;
|
|
92
|
+
background: var(--_bg); border: 0; box-shadow: 0 0 0 .5px color-mix(in srgb, var(--_fg) 22%, transparent), 0 2px 8px rgba(0,0,0,.16);
|
|
93
|
+
display: grid; place-items: center; cursor: pointer; padding: 0; color: var(--_fg); font-size: 12.5px; font-weight: 500;
|
|
94
|
+
font-variant-numeric: tabular-nums; letter-spacing: .01em; transform-origin: 0 100%;
|
|
95
|
+
transition: transform .16s var(--_ease), box-shadow .16s var(--_ease), opacity .16s; }
|
|
96
|
+
button.pin:hover { transform: scale(1.08); box-shadow: 0 0 0 .5px color-mix(in srgb, var(--_fg) 30%, transparent), 0 4px 14px rgba(0,0,0,.2); }
|
|
97
|
+
button.pin:active { transform: scale(.96); }
|
|
98
|
+
.pin.in { animation: lab-pin .26s var(--_ease); }
|
|
99
|
+
/* A new comment lands with one faint halo from its pin. */
|
|
100
|
+
button.pin.in::before { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
|
|
101
|
+
animation: lab-halo .9s cubic-bezier(.2, .7, .3, 1) forwards; }
|
|
102
|
+
.pin.resolved { color: var(--_muted); }
|
|
103
|
+
.pin.moved { opacity: .5; }
|
|
104
|
+
.pin.grab { touch-action: none; }
|
|
105
|
+
button.pin.dragging { cursor: grabbing; transform: scale(1.1); opacity: 1; box-shadow: 0 0 0 .5px color-mix(in srgb, var(--_fg) 30%, transparent), 0 8px 20px rgba(0,0,0,.22); }
|
|
106
|
+
.zone { position: absolute; background: color-mix(in srgb, var(--_accent) 5%, transparent);
|
|
107
|
+
border-radius: var(--_radius); pointer-events: none; }
|
|
108
|
+
.zone svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
|
109
|
+
.zone rect { fill: none; stroke-linecap: round; stroke-dasharray: 0 4; rx: var(--_radius); ry: var(--_radius); }
|
|
110
|
+
/* Light dots on a dark ring: the outline shows on black pages and white ones, whatever the system theme. */
|
|
111
|
+
.zone rect.ring { stroke: rgba(0,0,0,.5); stroke-width: 2.5; }
|
|
112
|
+
.zone rect.dots { stroke: #fff; stroke-width: 1; }
|
|
113
|
+
.zone.live { left: 0; top: 0; will-change: transform, width, height; animation: lab-fade .12s ease-out; }
|
|
114
|
+
.zone.saved { background: color-mix(in srgb, var(--_accent) 3%, transparent); }
|
|
115
|
+
.zone.saved.in { animation: lab-settle .36s var(--_ease); }
|
|
116
|
+
.pin.new::after { content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px; border-radius: 50%;
|
|
117
|
+
background: var(--_fg); box-shadow: 0 0 0 2px var(--_bg); }
|
|
118
|
+
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--_accent); margin: 0 6px 1px 0; }
|
|
119
|
+
.card { position: absolute; width: 320px; max-width: calc(100vw - 24px); background: var(--_bg); border-radius: var(--_radius);
|
|
120
|
+
box-shadow: var(--_shadow); padding: 12px 14px 14px; transform-origin: 14px 0; z-index: 1; }
|
|
121
|
+
.card.in, .float.in { animation: lab-in .18s var(--_ease); }
|
|
122
|
+
.card.out, .float.out { animation: lab-out .14s ease-in forwards; pointer-events: none; }
|
|
123
|
+
.item { display: grid; row-gap: 3px; }
|
|
124
|
+
.item + .item { margin-top: 14px; }
|
|
125
|
+
.meta { display: flex; align-items: baseline; gap: .5em; font-size: 12px; color: var(--_muted); margin: 0;
|
|
126
|
+
white-space: nowrap; letter-spacing: .01em; font-variant-numeric: tabular-nums; }
|
|
127
|
+
.meta b { font-weight: 500; font-size: 13px; letter-spacing: .005em; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
128
|
+
.meta .dot { flex: none; align-self: center; margin: 0; }
|
|
129
|
+
.meta .num { color: var(--_muted); font-variant-numeric: tabular-nums; margin-left: auto; }
|
|
130
|
+
.meta .ago { white-space: nowrap; }
|
|
131
|
+
.text { white-space: pre-wrap; overflow-wrap: anywhere; text-wrap: pretty; }
|
|
132
|
+
.row { display: flex; gap: 12px; justify-content: flex-start; align-items: center; margin-top: 8px; }
|
|
133
|
+
button.btn { border: 0; padding: 2px 0; cursor: pointer; background: none; color: var(--_muted); font-size: 12px;
|
|
134
|
+
letter-spacing: .01em; border-radius: 4px; transition: color .12s; }
|
|
135
|
+
button.btn:hover { color: var(--_fg); }
|
|
136
|
+
button.btn.danger, button.btn.danger:hover { color: #e5484d; }
|
|
137
|
+
.row .ask { white-space: nowrap; color: var(--_fg); font-size: 12px; letter-spacing: .01em; padding: 2px 0; animation: lab-fade .12s ease-out; }
|
|
138
|
+
.float { position: absolute; width: 320px; max-width: calc(100vw - 24px); transform-origin: 0 20px; z-index: 1; }
|
|
139
|
+
.card .composer { margin-top: 14px; }
|
|
140
|
+
.box { display: grid; grid-template-columns: 1fr auto; grid-template-areas: "text send"; align-items: center; column-gap: 8px;
|
|
141
|
+
background: var(--_bg); border-radius: var(--_radius); padding: 7px 7px 7px 14px; transition: box-shadow .15s; }
|
|
142
|
+
.float .box { box-shadow: var(--_shadow); }
|
|
143
|
+
.float .box:focus-within { box-shadow: var(--_shadow), 0 0 0 .5px color-mix(in srgb, var(--_fg) 28%, transparent); }
|
|
144
|
+
.card .box { background: var(--_soft); border-radius: max(calc(var(--_radius) - 2px), 0px); margin: 0; }
|
|
145
|
+
.card .box.open { box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--_fg) 22%, transparent); }
|
|
146
|
+
.card .box:focus-within { box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--_fg) 28%, transparent); }
|
|
147
|
+
/* Same top and right padding open or closed: the text and the arrow's column stay put while the box grows. */
|
|
148
|
+
.box.open { grid-template-areas: "text text" "line line" "who send"; row-gap: 8px; padding: 7px 7px 8px 14px; }
|
|
149
|
+
.box textarea { grid-area: text; width: 100%; border: 0; background: none; padding: 4px 0; margin: 0; resize: none;
|
|
150
|
+
min-height: 0; max-height: 200px; overflow-y: auto; outline: none; display: block; }
|
|
151
|
+
.box textarea::placeholder, .who input::placeholder { color: var(--_muted); opacity: 1; }
|
|
152
|
+
.line { grid-area: line; height: 0; border-top: var(--_hair); margin: 0 -7px 0 -14px; display: none; }
|
|
153
|
+
.who { grid-area: who; display: none; align-items: baseline; gap: 4px; min-width: 0; font-size: 13px; color: var(--_muted); letter-spacing: .005em; }
|
|
154
|
+
.who * { font-size: 13px; }
|
|
155
|
+
.who span { color: var(--_muted); }
|
|
156
|
+
.who input { flex: 1; min-width: 0; border: 0; background: none; padding: 0; margin: 0; outline: none; color: var(--_muted); text-overflow: ellipsis; }
|
|
157
|
+
.who input:focus { color: var(--_fg); }
|
|
158
|
+
.box.open .line { display: block; animation: lab-fade .16s ease-out; }
|
|
159
|
+
.box.open .who { display: flex; animation: lab-fade .16s ease-out; }
|
|
160
|
+
.send { grid-area: send; width: 24px; height: 24px; border-radius: 50%; border: 0; padding: 0; cursor: pointer;
|
|
161
|
+
background: var(--_line); color: var(--_muted); display: grid; place-items: center; transition: background .15s, color .15s, transform .15s var(--_ease); }
|
|
162
|
+
.send.ready:hover { transform: scale(1.06); }
|
|
163
|
+
.send.ready:active { transform: scale(.94); }
|
|
164
|
+
.send.ready { background: var(--_accent); color: var(--_accent-fg); }
|
|
165
|
+
.send svg { display: block; }
|
|
166
|
+
.send * { color: inherit; }
|
|
167
|
+
.error { color: #e5484d; font-size: 12px; padding: 6px 8px 0; letter-spacing: .01em; }
|
|
168
|
+
.error:empty { display: none; }
|
|
169
|
+
.bar { position: fixed; right: 16px; bottom: 16px; display: flex; gap: 8px; align-items: center; }
|
|
170
|
+
.bar button { border: 0; border-radius: 999px; padding: 9px 14px; cursor: pointer; background: var(--_bg); color: var(--_fg);
|
|
171
|
+
box-shadow: var(--_shadow); font-size: 13px; font-weight: 500; display: inline-flex; gap: 8px; align-items: center;
|
|
172
|
+
letter-spacing: .005em; font-variant-numeric: tabular-nums; white-space: nowrap; transition: color .15s, transform .15s var(--_ease); }
|
|
173
|
+
.bar button:active { transform: scale(.97); }
|
|
174
|
+
.bar button * { color: inherit; font-weight: inherit; }
|
|
175
|
+
.bar .toggle:not(.secondary)::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--_accent); }
|
|
176
|
+
.bar button.secondary { color: var(--_muted); }
|
|
177
|
+
.listener { position: fixed; left: 16px; bottom: 16px; display: inline-flex; gap: 6px; align-items: center; padding: 9px 14px; border-radius: 999px;
|
|
178
|
+
background: var(--_bg); color: var(--_fg); box-shadow: var(--_shadow); font-size: 13px; text-decoration: none; white-space: nowrap; }
|
|
179
|
+
.listener::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--_accent); margin-right: 2px; }
|
|
180
|
+
.listener span { color: var(--_muted); }
|
|
181
|
+
.listener.idle::before { background: var(--_muted); opacity: .5; }
|
|
182
|
+
kbd { font-size: 11px; line-height: 17px; min-width: 18px; padding: 0 4px; border-radius: 4px; text-align: center;
|
|
183
|
+
background: var(--_soft); color: var(--_muted) !important; }
|
|
184
|
+
.hint { position: fixed; left: 50%; bottom: 64px; transform: translateX(-50%); background: var(--_bg); color: var(--_muted);
|
|
185
|
+
box-shadow: var(--_shadow); padding: 8px 14px; border-radius: 999px; font-size: 13px; pointer-events: none; white-space: nowrap;
|
|
186
|
+
letter-spacing: .005em; animation: lab-fade .2s ease-out; }
|
|
187
|
+
/* 16px fields keep iOS from zooming the page when one is focused. */
|
|
188
|
+
@media (pointer: coarse) { .box textarea, .who input { font-size: 16px; letter-spacing: -.006em; } kbd { display: none; } }
|
|
189
|
+
@keyframes lab-fade { from { opacity: 0; } }
|
|
190
|
+
@keyframes lab-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
|
|
191
|
+
@keyframes lab-out { to { opacity: 0; transform: translateY(2px) scale(.985); } }
|
|
192
|
+
/* Continuous (Apple-style) corners where the browser draws them; plain rounding elsewhere. */
|
|
193
|
+
@supports (corner-shape: squircle) { .card, .float .box, .card .box { corner-shape: squircle; border-radius: calc(var(--_radius) * 1.8); } }
|
|
194
|
+
@keyframes lab-pin { from { transform: scale(1.1); } }
|
|
195
|
+
@keyframes lab-halo { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--_fg) 12%, transparent); }
|
|
196
|
+
to { box-shadow: 0 0 0 16px color-mix(in srgb, var(--_fg) 0%, transparent); } }
|
|
197
|
+
@keyframes lab-settle { from { background: color-mix(in srgb, var(--_accent) 16%, transparent);
|
|
198
|
+
box-shadow: 0 0 0 5px color-mix(in srgb, var(--_accent) 18%, transparent); }
|
|
199
|
+
to { box-shadow: 0 0 0 0 color-mix(in srgb, var(--_accent) 0%, transparent); } }
|
|
200
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
|
201
|
+
@media (max-width: 480px) { kbd { display: none; } }
|
|
202
|
+
[hidden] { display: none !important; }
|
|
203
|
+
</style>
|
|
204
|
+
<div class="layer"></div>
|
|
205
|
+
<div class="hint" hidden>click to comment · drag to mark an area · c to hide</div>
|
|
206
|
+
<div class="bar">
|
|
207
|
+
<button class="secondary copy-all" type="button" hidden>copy link</button>
|
|
208
|
+
<button class="toggle" type="button"></button>
|
|
209
|
+
</div>`;
|
|
210
|
+
const layer = root.querySelector('.layer');
|
|
211
|
+
const hint = root.querySelector('.hint');
|
|
212
|
+
if (matchMedia('(pointer: coarse)').matches) hint.textContent = 'tap to comment';
|
|
213
|
+
const toggleBtn = root.querySelector('.toggle');
|
|
214
|
+
const copyBtn = root.querySelector('.copy-all');
|
|
215
|
+
|
|
216
|
+
const el = (tag, attrs = {}, text) => {
|
|
217
|
+
const node = document.createElement(tag);
|
|
218
|
+
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
|
219
|
+
if (text != null) node.textContent = text;
|
|
220
|
+
return node;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// ---------- anchoring ----------
|
|
224
|
+
function selectorFor(node) {
|
|
225
|
+
const parts = [];
|
|
226
|
+
while (node && node.nodeType === 1 && node !== document.body && node !== document.documentElement) {
|
|
227
|
+
if (node.id && document.querySelectorAll(`#${CSS.escape(node.id)}`).length === 1) {
|
|
228
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
const tag = node.localName;
|
|
232
|
+
let index = 1;
|
|
233
|
+
for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) if (sib.localName === tag) index++;
|
|
234
|
+
parts.unshift(`${tag}:nth-of-type(${index})`);
|
|
235
|
+
node = node.parentElement;
|
|
236
|
+
}
|
|
237
|
+
return parts.length ? (parts[0].startsWith('#') ? '' : 'body > ') + parts.join(' > ') : 'body';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const docWidth = () => Math.max(document.documentElement.scrollWidth, 1);
|
|
241
|
+
|
|
242
|
+
const clamp01 = (v) => Math.min(Math.max(v, 0), 1);
|
|
243
|
+
const short = (v, n) => (v || '').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
244
|
+
|
|
245
|
+
// A source-file hint from a (possibly proxied, hashed or optimised) image URL: "hero-1" from
|
|
246
|
+
// /_astro/hero-1.Bx3kd9_Z.webp, /_image?href=..., /_vercel/image?url=..., /media/<id>/w800.webp.
|
|
247
|
+
function fileHint(url) {
|
|
248
|
+
if (!url || url.startsWith('data:')) return '';
|
|
249
|
+
try {
|
|
250
|
+
const u = new URL(url, location.href);
|
|
251
|
+
const inner = u.searchParams.get('href') || u.searchParams.get('url');
|
|
252
|
+
if (inner && /\/_(image|vercel\/image|next\/image)/.test(u.pathname)) return fileHint(inner);
|
|
253
|
+
const parts = u.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
254
|
+
let name = parts.pop() || '';
|
|
255
|
+
if (/^w?\d+(x\d+)?\.\w+$/i.test(name) && parts.length) name = parts.pop();
|
|
256
|
+
return name.replace(/\.(avif|webp|jpe?g|png|gif|svg|mp4|webm|mov)$/i, '').replace(/[._-][A-Za-z0-9_-]{8}$/, '');
|
|
257
|
+
} catch {
|
|
258
|
+
return '';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const mediaSrc = (m) => {
|
|
263
|
+
const src = m.currentSrc || m.getAttribute('src') || '';
|
|
264
|
+
if (src && !src.startsWith('data:')) return new URL(src, location.href).href;
|
|
265
|
+
const lazy = m.dataset?.src || (m.getAttribute('srcset') || m.dataset?.srcset || '').split(/\s+/)[0];
|
|
266
|
+
return lazy ? new URL(lazy, location.href).href : '';
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Where the click is inside the original image/video, taking object-fit and object-position into account.
|
|
270
|
+
function naturalSpot(m, rect, cx, cy, nw, nh) {
|
|
271
|
+
if (!nw || !nh || !rect.width || !rect.height) return null;
|
|
272
|
+
const cs = getComputedStyle(m);
|
|
273
|
+
const fit = cs.objectFit || 'fill';
|
|
274
|
+
const pos = (cs.objectPosition || '50% 50%').split(/\s+/).map((v) =>
|
|
275
|
+
v.endsWith('%') ? parseFloat(v) / 100 : { left: 0, top: 0, right: 1, bottom: 1 }[v] ?? 0.5,
|
|
276
|
+
);
|
|
277
|
+
const sx = rect.width / nw;
|
|
278
|
+
const sy = rect.height / nh;
|
|
279
|
+
const scale = { cover: Math.max(sx, sy), contain: Math.min(sx, sy), none: 1, 'scale-down': Math.min(1, sx, sy) }[fit];
|
|
280
|
+
const dw = nw * (scale ?? sx);
|
|
281
|
+
const dh = nh * (scale ?? sy);
|
|
282
|
+
const ox = (rect.width - dw) * (pos[0] ?? 0.5);
|
|
283
|
+
const oy = (rect.height - dh) * (pos[1] ?? pos[0] ?? 0.5);
|
|
284
|
+
return { fit, nw, nh, imgX: clamp01((cx - rect.left - ox) / dw), imgY: clamp01((cy - rect.top - oy) / dh) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The word under the click plus ~40 characters either side, so an agent can grep the source.
|
|
288
|
+
function quoteAt(cx, cy) {
|
|
289
|
+
let node = null;
|
|
290
|
+
let offset = 0;
|
|
291
|
+
if (document.caretPositionFromPoint) {
|
|
292
|
+
const p = document.caretPositionFromPoint(cx, cy);
|
|
293
|
+
if (p) (node = p.offsetNode), (offset = p.offset);
|
|
294
|
+
} else if (document.caretRangeFromPoint) {
|
|
295
|
+
const r = document.caretRangeFromPoint(cx, cy);
|
|
296
|
+
if (r) (node = r.startContainer), (offset = r.startOffset);
|
|
297
|
+
}
|
|
298
|
+
if (!node || node.nodeType !== 3 || !node.data.trim()) return null;
|
|
299
|
+
// Only when the click is really on the text, not in empty space next to it.
|
|
300
|
+
const range = document.createRange();
|
|
301
|
+
range.selectNodeContents(node);
|
|
302
|
+
const hit = [...range.getClientRects()].some((r) => cx >= r.left - 4 && cx <= r.right + 4 && cy >= r.top - 4 && cy <= r.bottom + 4);
|
|
303
|
+
if (!hit) return null;
|
|
304
|
+
const text = node.data;
|
|
305
|
+
let a = offset;
|
|
306
|
+
let b = offset;
|
|
307
|
+
while (a > 0 && /\S/.test(text[a - 1])) a--;
|
|
308
|
+
while (b < text.length && /\S/.test(text[b])) b++;
|
|
309
|
+
const block = node.parentElement?.closest('p, li, h1, h2, h3, h4, h5, h6, td, th, figcaption, blockquote, a, button, label, span, div') || node.parentElement;
|
|
310
|
+
const full = block?.textContent || text;
|
|
311
|
+
const before = full.slice(0, full.indexOf(text) + a);
|
|
312
|
+
const after = full.slice(full.indexOf(text) + b);
|
|
313
|
+
const squash = (v) => v.replace(/\s+/g, ' ');
|
|
314
|
+
const quote = {
|
|
315
|
+
exact: short(text.slice(a, b), 80),
|
|
316
|
+
prefix: squash(before.slice(-80)).replace(/^\s+/, '').slice(-40),
|
|
317
|
+
suffix: squash(after.slice(0, 80)).replace(/\s+$/, '').slice(0, 40),
|
|
318
|
+
};
|
|
319
|
+
// Code (or any preformatted text): the whole line, its number in the block, and the block's label
|
|
320
|
+
// (a figcaption, data-file, title or aria-label nearby), so an agent can open the file at that line.
|
|
321
|
+
const pre = node.parentElement?.closest('pre, [data-file]') ||
|
|
322
|
+
(/^pre/.test(getComputedStyle(node.parentElement).whiteSpace) ? node.parentElement : null);
|
|
323
|
+
if (pre) {
|
|
324
|
+
const r = document.createRange();
|
|
325
|
+
r.setStart(pre, 0);
|
|
326
|
+
r.setEnd(node, offset);
|
|
327
|
+
const upto = r.toString();
|
|
328
|
+
const all = pre.textContent;
|
|
329
|
+
const from = upto.lastIndexOf('\n') + 1;
|
|
330
|
+
const to = all.indexOf('\n', upto.length);
|
|
331
|
+
quote.line = short(all.slice(from, to < 0 ? undefined : to).trim(), 200);
|
|
332
|
+
quote.lineNo = upto.split('\n').length;
|
|
333
|
+
quote.lines = all.replace(/\n$/, '').split('\n').length;
|
|
334
|
+
const holder = pre.closest('[data-file], figure') || pre;
|
|
335
|
+
const label = holder.getAttribute('data-file') || holder.querySelector?.('figcaption')?.textContent ||
|
|
336
|
+
pre.getAttribute('title') || pre.getAttribute('aria-label') || '';
|
|
337
|
+
if (label.trim()) quote.label = short(label.trim(), 120);
|
|
338
|
+
}
|
|
339
|
+
return quote;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function openingTag(node) {
|
|
343
|
+
const attrs = [...node.attributes]
|
|
344
|
+
.filter((a) => /^(id|class|alt|role|aria-label|href|data-[\w-]+)$/.test(a.name))
|
|
345
|
+
.map((a) => `${a.name}="${short(a.value, 60)}"`);
|
|
346
|
+
return `<${node.localName}${attrs.length ? ` ${attrs.join(' ')}` : ''}>`.slice(0, 200);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function anchorAt(clientX, clientY) {
|
|
350
|
+
const stack = document.elementsFromPoint(clientX, clientY).filter((n) => n !== host && n !== document.documentElement);
|
|
351
|
+
// data-comment-target: a click anywhere inside snaps to that element (same attribute as Claude artifacts).
|
|
352
|
+
const target = stack[0]?.closest('[data-comment-target]') || stack[0] || document.body;
|
|
353
|
+
const rect = target.getBoundingClientRect();
|
|
354
|
+
const anchor = {
|
|
355
|
+
selector: selectorFor(target),
|
|
356
|
+
x: rect.width ? (clientX - rect.left) / rect.width : 0,
|
|
357
|
+
y: rect.height ? (clientY - rect.top) / rect.height : 0,
|
|
358
|
+
docX: (clientX + scrollX) / docWidth(),
|
|
359
|
+
docY: clientY + scrollY,
|
|
360
|
+
width: innerWidth,
|
|
361
|
+
height: innerHeight,
|
|
362
|
+
dpr: Math.round((devicePixelRatio || 1) * 100) / 100,
|
|
363
|
+
element: openingTag(target),
|
|
364
|
+
};
|
|
365
|
+
const own = short(target.textContent, 200);
|
|
366
|
+
if (own && target !== document.body && (target.textContent || '').length < 400) anchor.text = short(own, 80);
|
|
367
|
+
const inside = target.parentElement?.closest('[id], [data-lab]');
|
|
368
|
+
if (inside) anchor.inside = inside.dataset.lab ? `[data-lab="${inside.dataset.lab}"]` : `#${inside.id}`;
|
|
369
|
+
let heading = null;
|
|
370
|
+
for (const h of document.querySelectorAll('h1, h2, h3')) {
|
|
371
|
+
if (h.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING || h.contains(target)) heading = h;
|
|
372
|
+
}
|
|
373
|
+
if (heading) anchor.section = short(heading.textContent, 100);
|
|
374
|
+
const build = document.querySelector('meta[name="lab-build"]')?.content;
|
|
375
|
+
if (build) anchor.build = short(build, 40);
|
|
376
|
+
|
|
377
|
+
// What the pin sits on: media first (even under a transparent overlay), then text, then a background image.
|
|
378
|
+
const media = stack.slice(0, 5).find((n) => /^(img|video|canvas|svg)$/.test(n.localName) && !host.contains(n));
|
|
379
|
+
if (media) {
|
|
380
|
+
const r = media.getBoundingClientRect();
|
|
381
|
+
anchor.kind = media.localName === 'img' ? 'image' : media.localName;
|
|
382
|
+
if (media.localName === 'img' || media.localName === 'video') {
|
|
383
|
+
anchor.src = mediaSrc(media);
|
|
384
|
+
anchor.file = fileHint(anchor.src);
|
|
385
|
+
if (media.alt) anchor.alt = short(media.alt, 120);
|
|
386
|
+
const spot = media.localName === 'img'
|
|
387
|
+
? naturalSpot(media, r, clientX, clientY, media.naturalWidth, media.naturalHeight)
|
|
388
|
+
: naturalSpot(media, r, clientX, clientY, media.videoWidth, media.videoHeight);
|
|
389
|
+
if (spot) Object.assign(anchor, spot);
|
|
390
|
+
if (media.localName === 'video') {
|
|
391
|
+
anchor.time = Math.round(media.currentTime * 10) / 10;
|
|
392
|
+
if (Number.isFinite(media.duration)) anchor.duration = Math.round(media.duration * 10) / 10;
|
|
393
|
+
if (!anchor.file && media.poster) anchor.file = fileHint(media.poster);
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
// canvas / svg animation: when it was, and where inside it.
|
|
397
|
+
anchor.time = Math.round(performance.now() / 100) / 10;
|
|
398
|
+
anchor.imgX = clamp01((clientX - r.left) / (r.width || 1));
|
|
399
|
+
anchor.imgY = clamp01((clientY - r.top) / (r.height || 1));
|
|
400
|
+
anchor.nw = Math.round(r.width);
|
|
401
|
+
anchor.nh = Math.round(r.height);
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
const quote = quoteAt(clientX, clientY);
|
|
405
|
+
if (quote) {
|
|
406
|
+
anchor.kind = 'text';
|
|
407
|
+
anchor.quote = quote;
|
|
408
|
+
} else {
|
|
409
|
+
for (let n = target, i = 0; n && n !== document.documentElement && i < 4; n = n.parentElement, i++) {
|
|
410
|
+
const bg = getComputedStyle(n).backgroundImage.match(/url\(["']?(.+?)["']?\)/);
|
|
411
|
+
if (bg) {
|
|
412
|
+
anchor.kind = 'background';
|
|
413
|
+
anchor.src = new URL(bg[1], location.href).href;
|
|
414
|
+
anchor.file = fileHint(anchor.src);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
anchor.kind ||= 'element';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return anchor;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The stored selector can point at a different element after a redeploy; check it against the
|
|
425
|
+
// file / text fingerprint, look for the right one, and otherwise fall back to the page position.
|
|
426
|
+
function findTarget(anchor) {
|
|
427
|
+
let node = null;
|
|
428
|
+
try {
|
|
429
|
+
node = anchor.selector ? document.querySelector(anchor.selector) : null;
|
|
430
|
+
} catch {}
|
|
431
|
+
const matches = (n) => {
|
|
432
|
+
if (!n) return false;
|
|
433
|
+
if (anchor.file && /^(image|video)$/.test(anchor.kind)) {
|
|
434
|
+
const m = n.closest?.('img, video') || n;
|
|
435
|
+
return fileHint(mediaSrc(m)) === anchor.file;
|
|
436
|
+
}
|
|
437
|
+
if (anchor.element && openingTag(n) !== anchor.element) return false;
|
|
438
|
+
if (anchor.quote?.exact) return (n.textContent || '').includes(anchor.quote.exact);
|
|
439
|
+
return true;
|
|
440
|
+
};
|
|
441
|
+
if (node && node !== document.body && matches(node)) return node;
|
|
442
|
+
if (anchor.file && /^(image|video)$/.test(anchor.kind)) {
|
|
443
|
+
return [...document.querySelectorAll('img, video')].find((m) => fileHint(mediaSrc(m)) === anchor.file) || null;
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Pin point (the zone's top-left corner for area comments) and, for areas, the zone box, in page coordinates.
|
|
449
|
+
function position(anchor) {
|
|
450
|
+
const node = findTarget(anchor);
|
|
451
|
+
if (node) {
|
|
452
|
+
const rect = node.getBoundingClientRect();
|
|
453
|
+
if (rect.width || rect.height) {
|
|
454
|
+
const left = rect.left + scrollX + anchor.x * rect.width;
|
|
455
|
+
const top = rect.top + scrollY + anchor.y * rect.height;
|
|
456
|
+
const zone = anchor.zw ? { left, top, width: anchor.zw * rect.width, height: anchor.zh * rect.height } : null;
|
|
457
|
+
return { left, top, zone };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const left = anchor.docX * docWidth();
|
|
461
|
+
const top = anchor.docY;
|
|
462
|
+
const zone = anchor.docW ? { left, top, width: anchor.docW, height: anchor.docH } : null;
|
|
463
|
+
// Anchored to the page itself (a margin, or an area wider than any element): nothing has moved.
|
|
464
|
+
return { left, top, zone, moved: Boolean(anchor.selector) && anchor.selector !== 'body' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// The quoted words of a text comment, found again in the page, for the highlight (CSS Custom Highlight API:
|
|
468
|
+
// nothing in the page's DOM is touched).
|
|
469
|
+
function quoteRange(anchor) {
|
|
470
|
+
const q = anchor?.quote;
|
|
471
|
+
const node = q?.exact ? findTarget(anchor) : null;
|
|
472
|
+
if (!node) return null;
|
|
473
|
+
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
|
474
|
+
const tail = (q.prefix || '').slice(-12);
|
|
475
|
+
let first = null;
|
|
476
|
+
for (let t; (t = walker.nextNode()); ) {
|
|
477
|
+
for (let i = t.data.indexOf(q.exact); i !== -1; i = t.data.indexOf(q.exact, i + 1)) {
|
|
478
|
+
const range = document.createRange();
|
|
479
|
+
range.setStart(t, i);
|
|
480
|
+
range.setEnd(t, i + q.exact.length);
|
|
481
|
+
if (!tail || t.data.slice(0, i).replace(/\s+/g, ' ').endsWith(tail)) return range;
|
|
482
|
+
first ||= range;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return first;
|
|
486
|
+
}
|
|
487
|
+
const canMark = typeof Highlight === 'function' && Boolean(CSS.highlights);
|
|
488
|
+
const markQuote = (anchor) => {
|
|
489
|
+
if (!canMark) return;
|
|
490
|
+
const range = anchor && quoteRange(anchor);
|
|
491
|
+
if (range) CSS.highlights.set('lab-quote', new Highlight(range));
|
|
492
|
+
else CSS.highlights.delete('lab-quote');
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// The area's outline: round dots with room between them (CSS dotted borders can't space their dots).
|
|
496
|
+
const zoneEl = (cls) => {
|
|
497
|
+
const z = el('div', { class: `zone ${cls}` });
|
|
498
|
+
z.innerHTML = '<svg aria-hidden="true"><rect class="ring" width="100%" height="100%"/><rect class="dots" width="100%" height="100%"/></svg>';
|
|
499
|
+
return z;
|
|
500
|
+
};
|
|
501
|
+
const drawZone = (box, cls) => {
|
|
502
|
+
if (!box) return;
|
|
503
|
+
const z = zoneEl(cls);
|
|
504
|
+
Object.assign(z.style, { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` });
|
|
505
|
+
layer.append(z);
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// An area comment: facts come from the middle of the zone; the anchor element is the smallest one that
|
|
509
|
+
// contains the whole zone, so the zone is stored as fractions of it and follows it like a pin.
|
|
510
|
+
function zoneAnchor({ x0, y0, x1, y1 }) {
|
|
511
|
+
const left = Math.min(x0, x1);
|
|
512
|
+
const top = Math.min(y0, y1);
|
|
513
|
+
const right = Math.max(x0, x1);
|
|
514
|
+
const bottom = Math.max(y0, y1);
|
|
515
|
+
const anchor = anchorAt((left + right) / 2, (top + bottom) / 2);
|
|
516
|
+
let box = document.elementsFromPoint((left + right) / 2, (top + bottom) / 2).find((n) => n !== host && !host.contains(n));
|
|
517
|
+
const contains = (r) => r.left <= left + 2 && r.top <= top + 2 && r.right >= right - 2 && r.bottom >= bottom - 2;
|
|
518
|
+
while (box && box !== document.body && !contains(box.getBoundingClientRect())) box = box.parentElement;
|
|
519
|
+
box ||= document.body;
|
|
520
|
+
const r = box.getBoundingClientRect();
|
|
521
|
+
Object.assign(anchor, {
|
|
522
|
+
selector: box === document.body ? 'body' : selectorFor(box),
|
|
523
|
+
element: openingTag(box),
|
|
524
|
+
x: r.width ? (left - r.left) / r.width : 0,
|
|
525
|
+
y: r.height ? (top - r.top) / r.height : 0,
|
|
526
|
+
zw: r.width ? Math.min((right - left) / r.width, 1) : 0,
|
|
527
|
+
zh: r.height ? Math.min((bottom - top) / r.height, 1) : 0,
|
|
528
|
+
docX: (left + scrollX) / docWidth(),
|
|
529
|
+
docY: top + scrollY,
|
|
530
|
+
docW: Math.round(right - left),
|
|
531
|
+
docH: Math.round(bottom - top),
|
|
532
|
+
});
|
|
533
|
+
// Text inside the zone, read like a selection from its top-left to its bottom-right corner.
|
|
534
|
+
const caret = (x, y) => {
|
|
535
|
+
if (document.caretPositionFromPoint) {
|
|
536
|
+
const c = document.caretPositionFromPoint(x, y);
|
|
537
|
+
return c && { node: c.offsetNode, offset: c.offset };
|
|
538
|
+
}
|
|
539
|
+
const c = document.caretRangeFromPoint?.(x, y);
|
|
540
|
+
return c && { node: c.startContainer, offset: c.startOffset };
|
|
541
|
+
};
|
|
542
|
+
const from = caret(left + 1, top + 1);
|
|
543
|
+
const to = caret(right - 1, bottom - 1);
|
|
544
|
+
if (from && to && from.node.nodeType === 3 && to.node.nodeType === 3) {
|
|
545
|
+
try {
|
|
546
|
+
const range = document.createRange();
|
|
547
|
+
range.setStart(from.node, from.offset);
|
|
548
|
+
range.setEnd(to.node, to.offset);
|
|
549
|
+
const words = short(range.toString(), 300);
|
|
550
|
+
if (words) anchor.zoneText = words;
|
|
551
|
+
} catch {}
|
|
552
|
+
}
|
|
553
|
+
// The same area inside the original image or video file, when the zone is on one.
|
|
554
|
+
if (/^(img|video)$/.test(box.localName)) {
|
|
555
|
+
const nw = box.naturalWidth || box.videoWidth;
|
|
556
|
+
const nh = box.naturalHeight || box.videoHeight;
|
|
557
|
+
const a = naturalSpot(box, r, left, top, nw, nh);
|
|
558
|
+
const b = naturalSpot(box, r, right, bottom, nw, nh);
|
|
559
|
+
if (a && b) Object.assign(anchor, { imgX: a.imgX, imgY: a.imgY, imgX2: b.imgX, imgY2: b.imgY });
|
|
560
|
+
} else if (anchor.kind === 'image' || anchor.kind === 'video') {
|
|
561
|
+
// Zone is bigger than the media it is centred on: drop the single-point spot, keep the file.
|
|
562
|
+
delete anchor.imgX;
|
|
563
|
+
delete anchor.imgY;
|
|
564
|
+
}
|
|
565
|
+
return anchor;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------- data ----------
|
|
569
|
+
const headers = () => ({ 'content-type': 'application/json', ...(adminToken ? { 'x-lab-admin': adminToken } : {}) });
|
|
570
|
+
|
|
571
|
+
// The API answers 404 anywhere but on the lab host. A script loaded with a relative src can't tell the
|
|
572
|
+
// lab host from the deployment's own address, so the first answer decides: no API here, no layer.
|
|
573
|
+
let gone = false;
|
|
574
|
+
let loaded = false;
|
|
575
|
+
let retried = false;
|
|
576
|
+
const typed = () => Boolean(root.activeElement) || [...root.querySelectorAll('textarea')].some((t) => t.value.trim());
|
|
577
|
+
async function load() {
|
|
578
|
+
try {
|
|
579
|
+
const res = await fetch(`${API}?page=${encodeURIComponent(page)}`, { headers: headers(), cache: 'no-store' });
|
|
580
|
+
if (res.status === 404 && !loaded && script?.dataset.enable !== 'always') {
|
|
581
|
+
if (!retried) {
|
|
582
|
+
retried = true;
|
|
583
|
+
setTimeout(load, 1500);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
gone = true;
|
|
587
|
+
on = false;
|
|
588
|
+
document.documentElement.classList.remove('lab-pinning');
|
|
589
|
+
host.remove();
|
|
590
|
+
fonts.remove();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (!res.ok) throw new Error(res.status);
|
|
594
|
+
const data = await res.json();
|
|
595
|
+
comments = data.comments || [];
|
|
596
|
+
isAdmin = Boolean(data.admin);
|
|
597
|
+
loaded = true;
|
|
598
|
+
} catch {
|
|
599
|
+
// Keep what is already on screen when a refresh fails.
|
|
600
|
+
}
|
|
601
|
+
if (!typed() && !grabbing) render();
|
|
602
|
+
// Someone is in a box: the open thread gets its new replies in place; everything else shows on the next render.
|
|
603
|
+
else if (openCard?.isConnected) openCard.sync();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function send(payload) {
|
|
607
|
+
const res = await fetch(API, { method: 'POST', headers: headers(), body: JSON.stringify({ page, ...payload }) });
|
|
608
|
+
const data = await res.json().catch(() => ({}));
|
|
609
|
+
if (!res.ok) throw new Error(data.error || `could not save (${res.status})`);
|
|
610
|
+
return data;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---------- copy for an agent ----------
|
|
614
|
+
// The link an agent reads: every open comment on this page as plain text.
|
|
615
|
+
const agentLink = () => new URL('/_lab/agent' + page, new URL(API, location.href)).href;
|
|
616
|
+
|
|
617
|
+
async function copy(textPromise, button) {
|
|
618
|
+
const textValue = await textPromise;
|
|
619
|
+
try {
|
|
620
|
+
await navigator.clipboard.writeText(textValue);
|
|
621
|
+
} catch {
|
|
622
|
+
const area = document.createElement('textarea');
|
|
623
|
+
area.value = textValue;
|
|
624
|
+
area.style.cssText = 'position:fixed;opacity:0;';
|
|
625
|
+
document.body.append(area);
|
|
626
|
+
area.select();
|
|
627
|
+
document.execCommand('copy');
|
|
628
|
+
area.remove();
|
|
629
|
+
}
|
|
630
|
+
const label = button.textContent;
|
|
631
|
+
button.textContent = 'copied';
|
|
632
|
+
setTimeout(() => (button.textContent = label), 1400);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ---------- render ----------
|
|
636
|
+
const threads = () => comments.filter((c) => !c.parent && (isAdmin || !c.resolved));
|
|
637
|
+
// Open comments are numbered 1, 2, 3… in order, the same for everyone, admins and the agent text.
|
|
638
|
+
const numberOf = (c) =>
|
|
639
|
+
comments
|
|
640
|
+
.filter((x) => !x.parent && !x.resolved)
|
|
641
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
642
|
+
.indexOf(c) + 1;
|
|
643
|
+
|
|
644
|
+
// Unread: comments and replies by someone else that this browser hasn't opened yet (kept per page).
|
|
645
|
+
const seenKey = () => `lab-seen:${page}`;
|
|
646
|
+
const readSeen = () => new Set(store.get(seenKey()).split(',').filter(Boolean));
|
|
647
|
+
let seen = readSeen();
|
|
648
|
+
const isNew = (c) => !seen.has(c.id) && c.name !== store.get('lab-name');
|
|
649
|
+
const unread = (c) => [c, ...repliesOf(c.id)].filter(isNew);
|
|
650
|
+
const markSeen = (items) => {
|
|
651
|
+
for (const item of items) seen.add(item.id);
|
|
652
|
+
store.set(seenKey(), [...seen].join(','));
|
|
653
|
+
};
|
|
654
|
+
const repliesOf = (id) => comments.filter((c) => c.parent === id);
|
|
655
|
+
// Pins show who left them: the first letter of the name.
|
|
656
|
+
const initial = (name) => [...String(name || '').trim()][0]?.toUpperCase() || '';
|
|
657
|
+
const ago = (iso) => {
|
|
658
|
+
const s = Math.max(0, (Date.now() - new Date(iso)) / 1000);
|
|
659
|
+
const [n, unit] = s < 60 ? [0, ''] : s < 3600 ? [s / 60, 'minute'] : s < 86400 ? [s / 3600, 'hour'] : s < 2592000 ? [s / 86400, 'day'] : s < 31536000 ? [s / 2592000, 'month'] : [s / 31536000, 'year'];
|
|
660
|
+
if (!unit) return 'just now';
|
|
661
|
+
const k = Math.floor(n);
|
|
662
|
+
return `${k} ${unit}${k === 1 ? '' : 's'} ago`;
|
|
663
|
+
};
|
|
664
|
+
const when = (iso) =>
|
|
665
|
+
new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
666
|
+
|
|
667
|
+
function placeCard(card, left, top) {
|
|
668
|
+
layer.append(card);
|
|
669
|
+
const width = card.offsetWidth;
|
|
670
|
+
const maxLeft = scrollX + document.documentElement.clientWidth - width - 12;
|
|
671
|
+
card.style.left = `${Math.max(scrollX + 12, Math.min(left - 14, maxLeft))}px`;
|
|
672
|
+
card.style.top = `${top + 10}px`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const ARROW =
|
|
676
|
+
'<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path d="M8 13V3.5M3.5 8 8 3.5 12.5 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
677
|
+
|
|
678
|
+
// One field that grows: a pill while empty, a box with a divider and the name row once you type.
|
|
679
|
+
function composer({ placeholder, onSubmit, autofocus }) {
|
|
680
|
+
const wrap = el('div', { class: 'composer' });
|
|
681
|
+
const box = el('div', { class: 'box' });
|
|
682
|
+
const text = el('textarea', { placeholder, maxlength: '2000', rows: '1', 'aria-label': placeholder });
|
|
683
|
+
const who = el('label', { class: 'who' });
|
|
684
|
+
const as = el('span', {}, 'as');
|
|
685
|
+
const name = el('input', { type: 'text', placeholder: 'your name', maxlength: '60', autocomplete: 'name' });
|
|
686
|
+
name.value = store.get('lab-name');
|
|
687
|
+
who.append(as, name);
|
|
688
|
+
const sendBtn = el('button', { class: 'send', type: 'button', 'aria-label': 'send' });
|
|
689
|
+
sendBtn.innerHTML = ARROW;
|
|
690
|
+
const error = el('div', { class: 'error' });
|
|
691
|
+
box.append(text, el('div', { class: 'line' }), who, sendBtn);
|
|
692
|
+
wrap.append(box, error);
|
|
693
|
+
|
|
694
|
+
const sync = () => {
|
|
695
|
+
const open = text.value.length > 0 || root.activeElement === name || !!error.textContent;
|
|
696
|
+
// Opening moves the arrow from the text row to the name row: slide it there while the box grows, no jump.
|
|
697
|
+
const glide = open !== box.classList.contains('open') && box.isConnected && !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
698
|
+
const from = glide && { arrow: sendBtn.getBoundingClientRect(), height: box.getBoundingClientRect().height };
|
|
699
|
+
box.classList.toggle('open', open);
|
|
700
|
+
as.hidden = !name.value.trim();
|
|
701
|
+
sendBtn.classList.toggle('ready', Boolean(text.value.trim() && name.value.trim()));
|
|
702
|
+
text.style.height = 'auto';
|
|
703
|
+
text.style.height = `${text.scrollHeight}px`;
|
|
704
|
+
if (glide) {
|
|
705
|
+
const to = sendBtn.getBoundingClientRect();
|
|
706
|
+
const timing = { duration: 220, easing: 'cubic-bezier(.2, .7, .3, 1)' };
|
|
707
|
+
sendBtn.animate([{ transform: `translate(${from.arrow.left - to.left}px, ${from.arrow.top - to.top}px)` }, { transform: 'none' }], timing);
|
|
708
|
+
box.style.overflow = 'hidden';
|
|
709
|
+
box.animate([{ height: `${from.height}px` }, { height: `${box.getBoundingClientRect().height}px` }], timing).onfinish = () => (box.style.overflow = '');
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
let busy = false;
|
|
713
|
+
const go = async () => {
|
|
714
|
+
if (busy) return;
|
|
715
|
+
if (!text.value.trim()) return text.focus();
|
|
716
|
+
if (!name.value.trim()) {
|
|
717
|
+
box.classList.add('open');
|
|
718
|
+
return name.focus();
|
|
719
|
+
}
|
|
720
|
+
store.set('lab-name', name.value.trim());
|
|
721
|
+
busy = true;
|
|
722
|
+
error.textContent = '';
|
|
723
|
+
try {
|
|
724
|
+
const result = await onSubmit(name.value.trim(), text.value.trim());
|
|
725
|
+
if (result?.keep) {
|
|
726
|
+
text.value = '';
|
|
727
|
+
sync();
|
|
728
|
+
}
|
|
729
|
+
} catch (e) {
|
|
730
|
+
error.textContent = e.message;
|
|
731
|
+
sync();
|
|
732
|
+
}
|
|
733
|
+
busy = false;
|
|
734
|
+
};
|
|
735
|
+
const onKey = (e) => {
|
|
736
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
737
|
+
e.preventDefault();
|
|
738
|
+
go();
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
text.addEventListener('input', sync);
|
|
742
|
+
name.addEventListener('input', sync);
|
|
743
|
+
name.addEventListener('blur', () => setTimeout(sync, 0));
|
|
744
|
+
text.addEventListener('keydown', onKey);
|
|
745
|
+
name.addEventListener('keydown', onKey);
|
|
746
|
+
sendBtn.addEventListener('click', go);
|
|
747
|
+
// Keep focus in the field when the send button is pressed.
|
|
748
|
+
sendBtn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
749
|
+
requestAnimationFrame(sync);
|
|
750
|
+
if (autofocus) setTimeout(() => text.focus(), 0);
|
|
751
|
+
return wrap;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Draft composer sits to the right of its pin, like the pin is the avatar; below it when there is no room.
|
|
755
|
+
function placeBeside(node, left, top) {
|
|
756
|
+
layer.append(node);
|
|
757
|
+
const width = node.offsetWidth;
|
|
758
|
+
const right = scrollX + document.documentElement.clientWidth - 12;
|
|
759
|
+
if (left + 36 + width <= right) {
|
|
760
|
+
node.style.left = `${left + 36}px`;
|
|
761
|
+
node.style.top = `${top - 37}px`;
|
|
762
|
+
} else {
|
|
763
|
+
node.style.left = `${Math.max(scrollX + 12, Math.min(left - 14, right - width))}px`;
|
|
764
|
+
node.style.top = `${top + 10}px`;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// What the last render had open (a thread id or a draft), so cards, pins and areas only animate when they
|
|
769
|
+
// appear, not on every re-render, and a card that closes to nothing can fade out.
|
|
770
|
+
let shown = null;
|
|
771
|
+
let posted = null;
|
|
772
|
+
const still = matchMedia('(prefers-reduced-motion: reduce)');
|
|
773
|
+
function render() {
|
|
774
|
+
grabbing = false; // a pin being dragged is replaced: its drag ends here
|
|
775
|
+
const prev = layer.querySelector('.card:not(.out), .float:not(.out)');
|
|
776
|
+
layer.replaceChildren();
|
|
777
|
+
const now = on ? draft || openId : null;
|
|
778
|
+
const appear = now !== shown;
|
|
779
|
+
const anim = appear ? ' in' : '';
|
|
780
|
+
let quoted = null;
|
|
781
|
+
const done = () => {
|
|
782
|
+
if (!now && shown && prev && !still.matches) {
|
|
783
|
+
prev.classList.remove('in');
|
|
784
|
+
prev.classList.add('out');
|
|
785
|
+
prev.inert = true;
|
|
786
|
+
layer.append(prev);
|
|
787
|
+
setTimeout(() => prev.remove(), 150);
|
|
788
|
+
}
|
|
789
|
+
markQuote(quoted);
|
|
790
|
+
shown = now;
|
|
791
|
+
posted = null;
|
|
792
|
+
};
|
|
793
|
+
const list = threads();
|
|
794
|
+
const open = list.filter((c) => !c.resolved).length;
|
|
795
|
+
const fresh = list.filter((c) => !c.resolved).reduce((n, c) => n + unread(c).length, 0);
|
|
796
|
+
toggleBtn.replaceChildren(
|
|
797
|
+
document.createTextNode(
|
|
798
|
+
(on ? 'hide comments' : open ? `comments · ${open}` : 'comments') + (fresh ? ` · ${fresh} new` : ''),
|
|
799
|
+
),
|
|
800
|
+
el('kbd', {}, 'c'),
|
|
801
|
+
);
|
|
802
|
+
toggleBtn.classList.toggle('secondary', on);
|
|
803
|
+
document.documentElement.classList.toggle('lab-pinning', on);
|
|
804
|
+
copyBtn.hidden = !on || !isAdmin || list.every((c) => c.resolved);
|
|
805
|
+
if (!on) return done();
|
|
806
|
+
|
|
807
|
+
list.forEach((c) => {
|
|
808
|
+
const { left, top, moved, zone } = position(c.anchor || {});
|
|
809
|
+
if (openId === c.id) drawZone(zone, `saved${anim}`);
|
|
810
|
+
const news = unread(c);
|
|
811
|
+
const movable = !zone && (isAdmin || store.get(`lab-edit:${c.id}`));
|
|
812
|
+
const pin = el(
|
|
813
|
+
'button',
|
|
814
|
+
{
|
|
815
|
+
class: `pin${c.resolved ? ' resolved' : ''}${news.length ? ' new' : ''}${moved ? ' moved' : ''}${posted === c.id ? ' in' : ''}${movable ? ' grab' : ''}`,
|
|
816
|
+
type: 'button',
|
|
817
|
+
title: `${c.resolved ? '' : `${numberOf(c)} · `}${c.name}${moved ? ' · the spot it was pinned to has changed' : ''}${movable ? ' · drag to move' : ''}`,
|
|
818
|
+
},
|
|
819
|
+
c.resolved ? '✓' : initial(c.name),
|
|
820
|
+
);
|
|
821
|
+
pin.style.left = `${left}px`;
|
|
822
|
+
pin.style.top = `${top}px`;
|
|
823
|
+
if (movable) grab(pin, c);
|
|
824
|
+
pin.addEventListener('click', (e) => {
|
|
825
|
+
e.stopPropagation();
|
|
826
|
+
if (pin.dataset.dragged) return;
|
|
827
|
+
openId = openId === c.id ? null : c.id;
|
|
828
|
+
draft = null;
|
|
829
|
+
render();
|
|
830
|
+
});
|
|
831
|
+
layer.append(pin);
|
|
832
|
+
if (openId === c.id) {
|
|
833
|
+
// Area comments open below the area, so the card doesn't hide what it is about.
|
|
834
|
+
// Opened by a click: the reply box takes focus (not on touch screens, where it would pop the keyboard).
|
|
835
|
+
const card = threadCard(c, new Set(news.map((x) => x.id)), appear && !coarse);
|
|
836
|
+
openCard = card;
|
|
837
|
+
if (appear) card.classList.add('in');
|
|
838
|
+
placeCard(card, zone ? left + 14 : left, zone ? zone.top + zone.height : top);
|
|
839
|
+
markSeen(news);
|
|
840
|
+
quoted = c.anchor;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
if (draft) {
|
|
845
|
+
const { anchor } = draft;
|
|
846
|
+
const { left, top, zone } = position(anchor);
|
|
847
|
+
drawZone(zone, 'draft');
|
|
848
|
+
quoted = anchor;
|
|
849
|
+
const pin = el('div', { class: `pin draft${anim}` }, initial(store.get('lab-name')));
|
|
850
|
+
pin.style.left = `${left}px`;
|
|
851
|
+
pin.style.top = `${top}px`;
|
|
852
|
+
layer.append(pin);
|
|
853
|
+
const float = el('div', { class: `float${anim}` });
|
|
854
|
+
float.append(
|
|
855
|
+
composer({
|
|
856
|
+
placeholder: 'add a comment',
|
|
857
|
+
autofocus: true,
|
|
858
|
+
onSubmit: async (name, text) => {
|
|
859
|
+
const { comment, edit } = await send({ name, text, anchor });
|
|
860
|
+
if (edit) store.set(`lab-edit:${comment.id}`, edit); // lets this browser drag the pin later
|
|
861
|
+
comments.push(comment);
|
|
862
|
+
markSeen([comment]);
|
|
863
|
+
draft = null;
|
|
864
|
+
openId = null; // like Figma: once posted, the card closes and the new pin stays (click it to open)
|
|
865
|
+
posted = comment.id;
|
|
866
|
+
render();
|
|
867
|
+
},
|
|
868
|
+
}),
|
|
869
|
+
);
|
|
870
|
+
if (zone) placeCard(float, left + 14, zone.top + zone.height);
|
|
871
|
+
else placeBeside(float, left, top);
|
|
872
|
+
}
|
|
873
|
+
done();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Drag a pin to fix its spot: whoever started the thread (its key is kept in their browser) or an admin.
|
|
877
|
+
// The pin's tip (bottom left) lands where it is dropped; the new spot is saved like a fresh pin.
|
|
878
|
+
let grabbing = false;
|
|
879
|
+
function grab(pin, c) {
|
|
880
|
+
pin.addEventListener('pointerdown', (e) => {
|
|
881
|
+
if (e.button !== 0) return;
|
|
882
|
+
const r = pin.getBoundingClientRect();
|
|
883
|
+
const tip = { dx: e.clientX - r.left, dy: e.clientY - r.bottom };
|
|
884
|
+
const start = { x: e.clientX, y: e.clientY, sx: scrollX, sy: scrollY, left: parseFloat(pin.style.left), top: parseFloat(pin.style.top) };
|
|
885
|
+
let moving = false;
|
|
886
|
+
let last = e;
|
|
887
|
+
grabbing = true;
|
|
888
|
+
pin.setPointerCapture(e.pointerId);
|
|
889
|
+
// Page coordinates, so the pin stays under the pointer when the page scrolls mid-drag.
|
|
890
|
+
const place = () => {
|
|
891
|
+
pin.style.left = `${start.left + last.clientX - start.x + scrollX - start.sx}px`;
|
|
892
|
+
pin.style.top = `${start.top + last.clientY - start.y + scrollY - start.sy}px`;
|
|
893
|
+
};
|
|
894
|
+
const move = (ev) => {
|
|
895
|
+
last = ev;
|
|
896
|
+
if (!moving && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < 5) return;
|
|
897
|
+
moving = true;
|
|
898
|
+
pin.classList.add('dragging');
|
|
899
|
+
place();
|
|
900
|
+
};
|
|
901
|
+
const scrolled = () => moving && place();
|
|
902
|
+
addEventListener('scroll', scrolled, true);
|
|
903
|
+
const up = async (ev) => {
|
|
904
|
+
pin.removeEventListener('pointermove', move);
|
|
905
|
+
pin.removeEventListener('pointerup', up);
|
|
906
|
+
pin.removeEventListener('pointercancel', up);
|
|
907
|
+
pin.removeEventListener('lostpointercapture', up);
|
|
908
|
+
removeEventListener('scroll', scrolled, true);
|
|
909
|
+
if (!grabbing) return; // already ended (the layer was redrawn mid-drag)
|
|
910
|
+
grabbing = false;
|
|
911
|
+
if (!moving) return;
|
|
912
|
+
pin.dataset.dragged = '1'; // the click that follows is the drop, not an open
|
|
913
|
+
setTimeout(() => delete pin.dataset.dragged, 0);
|
|
914
|
+
const x = ev.clientX - tip.dx;
|
|
915
|
+
const y = ev.clientY - tip.dy;
|
|
916
|
+
const under = document.elementsFromPoint(x, y).find((n) => n !== host);
|
|
917
|
+
if (ev.type === 'pointercancel' || x < 0 || y < 0 || x > innerWidth || y > innerHeight || under?.closest(OFF)) return render();
|
|
918
|
+
const before = c.anchor;
|
|
919
|
+
c.anchor = anchorAt(x, y);
|
|
920
|
+
render();
|
|
921
|
+
try {
|
|
922
|
+
const { comment } = await send({ action: 'move', id: c.id, anchor: c.anchor, edit: store.get(`lab-edit:${c.id}`) || undefined });
|
|
923
|
+
Object.assign(c, comment);
|
|
924
|
+
} catch {
|
|
925
|
+
c.anchor = before; // not saved: back where it was
|
|
926
|
+
render();
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
pin.addEventListener('pointermove', move);
|
|
930
|
+
pin.addEventListener('pointerup', up);
|
|
931
|
+
pin.addEventListener('pointercancel', up);
|
|
932
|
+
pin.addEventListener('lostpointercapture', up);
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// The open thread's card. New replies (Claude's too) are added to it in place, so the reply box keeps focus.
|
|
937
|
+
let openCard = null;
|
|
938
|
+
const coarse = matchMedia('(pointer: coarse)').matches;
|
|
939
|
+
function threadCard(c, fresh = new Set(), focus = false) {
|
|
940
|
+
const card = el('div', { class: 'card' });
|
|
941
|
+
card.addEventListener('click', (e) => e.stopPropagation());
|
|
942
|
+
const itemEl = (item, isNew = fresh.has(item.id)) => {
|
|
943
|
+
const box = el('div', { class: 'item' });
|
|
944
|
+
const meta = el('div', { class: 'meta' });
|
|
945
|
+
meta.append(el('b', {}, item.name), el('span', { class: 'ago', title: when(item.createdAt) }, ago(item.createdAt)));
|
|
946
|
+
if (isNew) meta.append(el('span', { class: 'dot', title: 'new' }));
|
|
947
|
+
// The number the agent text uses for this thread.
|
|
948
|
+
if (item === c && !c.resolved) meta.append(el('span', { class: 'num', title: 'comment number, as in the agent link' }, String(numberOf(c))));
|
|
949
|
+
box.append(meta, el('div', { class: 'text' }, item.text));
|
|
950
|
+
return box;
|
|
951
|
+
};
|
|
952
|
+
const shown = new Set();
|
|
953
|
+
for (const item of [c, ...repliesOf(c.id)]) {
|
|
954
|
+
card.append(itemEl(item));
|
|
955
|
+
shown.add(item.id);
|
|
956
|
+
}
|
|
957
|
+
card.sync = () => {
|
|
958
|
+
for (const r of repliesOf(c.id)) {
|
|
959
|
+
if (shown.has(r.id)) continue;
|
|
960
|
+
const items = card.querySelectorAll('.item');
|
|
961
|
+
items[items.length - 1].after(itemEl(r, true));
|
|
962
|
+
shown.add(r.id);
|
|
963
|
+
markSeen([r]);
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
if (isAdmin) {
|
|
967
|
+
const actions = el('div', { class: 'row' });
|
|
968
|
+
const resolve = el('button', { class: 'btn', type: 'button' }, c.resolved ? 'reopen' : 'resolve');
|
|
969
|
+
const failed = (btn) => (btn.textContent = 'failed, try again');
|
|
970
|
+
resolve.addEventListener('click', async () => {
|
|
971
|
+
try {
|
|
972
|
+
const { comment } = await send({ action: c.resolved ? 'reopen' : 'resolve', id: c.id });
|
|
973
|
+
Object.assign(c, comment);
|
|
974
|
+
render();
|
|
975
|
+
} catch {
|
|
976
|
+
failed(resolve);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
const del = el('button', { class: 'btn', type: 'button' }, 'delete');
|
|
980
|
+
// Asked in the card itself, not the browser's dialog: the row swaps to the question, same height, nothing moves.
|
|
981
|
+
const yes = el('button', { class: 'btn danger', type: 'button' }, 'delete');
|
|
982
|
+
const no = el('button', { class: 'btn', type: 'button' }, 'cancel');
|
|
983
|
+
const back = () => {
|
|
984
|
+
actions.replaceChildren(resolve, del);
|
|
985
|
+
del.focus();
|
|
986
|
+
};
|
|
987
|
+
del.addEventListener('click', () => {
|
|
988
|
+
actions.replaceChildren(el('span', { class: 'ask' }, repliesOf(c.id).length ? 'delete thread?' : 'delete comment?'), yes, no);
|
|
989
|
+
no.focus();
|
|
990
|
+
});
|
|
991
|
+
no.addEventListener('click', back);
|
|
992
|
+
actions.addEventListener('keydown', (e) => {
|
|
993
|
+
if (e.key === 'Escape' && yes.isConnected) {
|
|
994
|
+
e.stopPropagation();
|
|
995
|
+
back();
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
yes.addEventListener('click', async () => {
|
|
999
|
+
try {
|
|
1000
|
+
await send({ action: 'delete', id: c.id }); // the API deletes the replies with it
|
|
1001
|
+
} catch {
|
|
1002
|
+
return failed(yes);
|
|
1003
|
+
}
|
|
1004
|
+
comments = comments.filter((x) => x.id !== c.id && x.parent !== c.id);
|
|
1005
|
+
openId = null;
|
|
1006
|
+
render();
|
|
1007
|
+
});
|
|
1008
|
+
actions.append(resolve, del);
|
|
1009
|
+
card.append(actions);
|
|
1010
|
+
}
|
|
1011
|
+
card.append(
|
|
1012
|
+
composer({
|
|
1013
|
+
placeholder: 'reply',
|
|
1014
|
+
autofocus: focus,
|
|
1015
|
+
onSubmit: async (name, text) => {
|
|
1016
|
+
const { comment } = await send({ name, text, parent: c.id });
|
|
1017
|
+
comments.push(comment);
|
|
1018
|
+
markSeen([comment]);
|
|
1019
|
+
// Add the reply where it belongs instead of redrawing the card, so nothing on screen blinks
|
|
1020
|
+
// and the reply box keeps its focus for the next one.
|
|
1021
|
+
shown.add(comment.id);
|
|
1022
|
+
const items = card.querySelectorAll('.item');
|
|
1023
|
+
items[items.length - 1].after(itemEl(comment, false));
|
|
1024
|
+
return { keep: true };
|
|
1025
|
+
},
|
|
1026
|
+
}),
|
|
1027
|
+
);
|
|
1028
|
+
return card;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ---------- events ----------
|
|
1032
|
+
const showHint = () => {
|
|
1033
|
+
if (store.get('lab-hint-seen') === '1') return;
|
|
1034
|
+
hint.hidden = false;
|
|
1035
|
+
store.set('lab-hint-seen', '1');
|
|
1036
|
+
setTimeout(() => (hint.hidden = true), 5000);
|
|
1037
|
+
};
|
|
1038
|
+
const setOn = (value) => {
|
|
1039
|
+
on = value;
|
|
1040
|
+
store.set('lab-on', on ? '1' : '0');
|
|
1041
|
+
if (on) showHint();
|
|
1042
|
+
draft = null;
|
|
1043
|
+
openId = null;
|
|
1044
|
+
render();
|
|
1045
|
+
};
|
|
1046
|
+
toggleBtn.addEventListener('click', () => setOn(!on));
|
|
1047
|
+
// Option-click (or Option-drag) with comments hidden still pins: comments come back with the new one.
|
|
1048
|
+
const showAgain = () => {
|
|
1049
|
+
if (on) return;
|
|
1050
|
+
on = true;
|
|
1051
|
+
store.set('lab-on', '1');
|
|
1052
|
+
};
|
|
1053
|
+
copyBtn.addEventListener('click', () => copy(agentLink(), copyBtn));
|
|
1054
|
+
const typing = (e) =>
|
|
1055
|
+
e.composedPath().some((n) => n instanceof HTMLElement && (n.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(n.tagName)));
|
|
1056
|
+
document.addEventListener('keydown', (e) => {
|
|
1057
|
+
if (gone) return;
|
|
1058
|
+
const inOtherWidget = document.activeElement?.localName.includes('-') && document.activeElement !== host; // e.g. an input in a closed shadow root
|
|
1059
|
+
if ((e.key === 'c' || e.key === 'C' || e.code === 'KeyC') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.repeat && !typing(e) && !inOtherWidget) {
|
|
1060
|
+
e.preventDefault();
|
|
1061
|
+
setOn(!on);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (e.key === 'Escape' && (draft || openId)) {
|
|
1065
|
+
draft = null;
|
|
1066
|
+
openId = null;
|
|
1067
|
+
render();
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// Always ready to pin: a click on the page (not on a link or control) opens a new comment there.
|
|
1072
|
+
// Alt/Option-click pins on links and controls too. With a comment open, the first click outside only closes it.
|
|
1073
|
+
const INTERACTIVE =
|
|
1074
|
+
'a, button, input, textarea, select, label, summary, video[controls], audio, iframe, [contenteditable], [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], [role="checkbox"], [role="switch"], [tabindex]:not([tabindex="-1"]), [onclick]';
|
|
1075
|
+
// data-uncommentable: no new pins or areas start inside this part of the page (same attribute as Claude artifacts).
|
|
1076
|
+
const OFF = '[data-uncommentable]';
|
|
1077
|
+
// Drag (mouse or pen) to mark an area instead of a point. Touch keeps scrolling.
|
|
1078
|
+
let drag = null;
|
|
1079
|
+
let afterDrag = false;
|
|
1080
|
+
let liveZone = null;
|
|
1081
|
+
let zoneFrame = 0;
|
|
1082
|
+
// One write per frame, position by transform: the frame follows the pointer without forcing layout in between.
|
|
1083
|
+
const paintZone = () => {
|
|
1084
|
+
zoneFrame = 0;
|
|
1085
|
+
if (!drag?.active || !liveZone) return;
|
|
1086
|
+
const x = Math.min(drag.x0, drag.x1) + scrollX;
|
|
1087
|
+
const y = Math.min(drag.y0, drag.y1) + scrollY;
|
|
1088
|
+
liveZone.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
1089
|
+
liveZone.style.width = `${Math.abs(drag.x1 - drag.x0)}px`;
|
|
1090
|
+
liveZone.style.height = `${Math.abs(drag.y1 - drag.y0)}px`;
|
|
1091
|
+
};
|
|
1092
|
+
document.addEventListener(
|
|
1093
|
+
'pointerdown',
|
|
1094
|
+
(e) => {
|
|
1095
|
+
if ((!on && !e.altKey) || draft || openId || e.button !== 0 || e.pointerType === 'touch' || e.metaKey || e.ctrlKey || e.shiftKey) return;
|
|
1096
|
+
if (e.composedPath().includes(host)) return;
|
|
1097
|
+
if (!e.altKey && e.target instanceof Element && e.target.closest(INTERACTIVE)) return;
|
|
1098
|
+
if (e.target instanceof Element && e.target.closest(OFF)) return;
|
|
1099
|
+
drag = { x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY, active: false };
|
|
1100
|
+
},
|
|
1101
|
+
true,
|
|
1102
|
+
);
|
|
1103
|
+
document.addEventListener(
|
|
1104
|
+
'pointermove',
|
|
1105
|
+
(e) => {
|
|
1106
|
+
if (!drag) return;
|
|
1107
|
+
drag.x1 = e.clientX;
|
|
1108
|
+
drag.y1 = e.clientY;
|
|
1109
|
+
if (!drag.active && Math.hypot(drag.x1 - drag.x0, drag.y1 - drag.y0) > 8) {
|
|
1110
|
+
drag.active = true;
|
|
1111
|
+
getSelection?.()?.removeAllRanges();
|
|
1112
|
+
document.documentElement.classList.add('lab-dragging');
|
|
1113
|
+
liveZone = zoneEl('draft live');
|
|
1114
|
+
layer.append(liveZone);
|
|
1115
|
+
}
|
|
1116
|
+
if (!drag.active) return;
|
|
1117
|
+
e.preventDefault();
|
|
1118
|
+
zoneFrame ||= requestAnimationFrame(paintZone);
|
|
1119
|
+
},
|
|
1120
|
+
true,
|
|
1121
|
+
);
|
|
1122
|
+
const endDrag = (e) => {
|
|
1123
|
+
if (!drag) return;
|
|
1124
|
+
const d = drag;
|
|
1125
|
+
drag = null;
|
|
1126
|
+
document.documentElement.classList.remove('lab-dragging');
|
|
1127
|
+
cancelAnimationFrame(zoneFrame);
|
|
1128
|
+
zoneFrame = 0;
|
|
1129
|
+
liveZone?.remove();
|
|
1130
|
+
liveZone = null;
|
|
1131
|
+
if (!d.active || e.type === 'pointercancel') return;
|
|
1132
|
+
// The click that follows this pointerup belongs to the drag, not a new pin.
|
|
1133
|
+
afterDrag = true;
|
|
1134
|
+
setTimeout(() => (afterDrag = false), 0);
|
|
1135
|
+
getSelection?.()?.removeAllRanges();
|
|
1136
|
+
if (Math.abs(d.x1 - d.x0) < 12 || Math.abs(d.y1 - d.y0) < 12) return;
|
|
1137
|
+
showAgain();
|
|
1138
|
+
draft = { anchor: zoneAnchor(d) };
|
|
1139
|
+
render();
|
|
1140
|
+
};
|
|
1141
|
+
document.addEventListener('pointerup', endDrag, true);
|
|
1142
|
+
document.addEventListener('pointercancel', endDrag, true);
|
|
1143
|
+
// Don't let images start a native drag while marking an area.
|
|
1144
|
+
document.addEventListener('dragstart', (e) => drag && e.preventDefault(), true);
|
|
1145
|
+
|
|
1146
|
+
document.addEventListener(
|
|
1147
|
+
'click',
|
|
1148
|
+
(e) => {
|
|
1149
|
+
if (!afterDrag) return;
|
|
1150
|
+
e.preventDefault();
|
|
1151
|
+
e.stopPropagation();
|
|
1152
|
+
},
|
|
1153
|
+
true,
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
document.addEventListener('click', (e) => {
|
|
1157
|
+
if (e.composedPath().includes(host)) return;
|
|
1158
|
+
if (draft || openId) {
|
|
1159
|
+
if ([...root.querySelectorAll('textarea')].some((t) => t.value.trim())) return; // typed text stays; Escape discards it
|
|
1160
|
+
draft = null;
|
|
1161
|
+
openId = null;
|
|
1162
|
+
render();
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if ((!on && !e.altKey) || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey) return;
|
|
1166
|
+
if (e.altKey) e.preventDefault();
|
|
1167
|
+
else if (e.defaultPrevented || (e.target instanceof Element && e.target.closest(INTERACTIVE))) return;
|
|
1168
|
+
if (e.target instanceof Element && e.target.closest(OFF)) return;
|
|
1169
|
+
if (String(getSelection?.() || '').trim()) return;
|
|
1170
|
+
showAgain();
|
|
1171
|
+
draft = { anchor: anchorAt(e.clientX, e.clientY) };
|
|
1172
|
+
render();
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
let frame = 0;
|
|
1176
|
+
const reflow = () => {
|
|
1177
|
+
cancelAnimationFrame(frame);
|
|
1178
|
+
frame = requestAnimationFrame(() => {
|
|
1179
|
+
// Keep an open composer's text: only re-place when nothing is being typed.
|
|
1180
|
+
if (draft || root.activeElement || grabbing) return;
|
|
1181
|
+
render();
|
|
1182
|
+
});
|
|
1183
|
+
};
|
|
1184
|
+
addEventListener('resize', reflow);
|
|
1185
|
+
addEventListener('scroll', (e) => e.target !== document && !host.contains(e.target) && reflow(), { capture: true, passive: true });
|
|
1186
|
+
|
|
1187
|
+
// Client-side navigation (pushState, back/forward, Astro view transitions): comments follow the new page,
|
|
1188
|
+
// and the layer comes back if the page swapped out <body>.
|
|
1189
|
+
const navigated = () => {
|
|
1190
|
+
if (gone) return;
|
|
1191
|
+
if (!host.isConnected && document.body) document.body.append(host);
|
|
1192
|
+
if (!fonts.isConnected) document.head.append(fonts);
|
|
1193
|
+
if (pageNow() === page) return;
|
|
1194
|
+
page = pageNow();
|
|
1195
|
+
seen = readSeen();
|
|
1196
|
+
comments = [];
|
|
1197
|
+
draft = null;
|
|
1198
|
+
openId = null;
|
|
1199
|
+
render();
|
|
1200
|
+
load();
|
|
1201
|
+
};
|
|
1202
|
+
for (const m of ['pushState', 'replaceState']) {
|
|
1203
|
+
const original = history[m];
|
|
1204
|
+
history[m] = function (...args) {
|
|
1205
|
+
const out = original.apply(this, args);
|
|
1206
|
+
queueMicrotask(navigated);
|
|
1207
|
+
return out;
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
addEventListener('popstate', navigated);
|
|
1211
|
+
document.addEventListener('astro:after-swap', navigated);
|
|
1212
|
+
// Pick up comments others left while this tab was in the background.
|
|
1213
|
+
document.addEventListener('visibilitychange', () => {
|
|
1214
|
+
if (document.visibilityState === 'visible' && !draft && !root.activeElement) load();
|
|
1215
|
+
});
|
|
1216
|
+
if ('ResizeObserver' in window) new ResizeObserver(reflow).observe(document.body);
|
|
1217
|
+
|
|
1218
|
+
// Font: Inter 4 (400 and 500, latin, with its OpenType features) from the fonts folder next to this script, under a private family name so it
|
|
1219
|
+
// can't clash with the page's fonts. @font-face is ignored inside a shadow root, so it goes in the document.
|
|
1220
|
+
// Other fonts: --lab-font on the host element, data-font="Name" (a font the page already loads) or data-font-url (a woff2).
|
|
1221
|
+
const fonts = document.createElement('style');
|
|
1222
|
+
const fontFile = (weight) => new URL(`fonts/inter-latin-${weight}.woff2`, script?.src || new URL('/_lab/', location.href));
|
|
1223
|
+
fonts.textContent = [400, 500]
|
|
1224
|
+
.map((w) => `@font-face{font-family:'lab-inter';font-style:normal;font-weight:${w};font-display:swap;src:url('${fontFile(w)}') format('woff2');}`)
|
|
1225
|
+
.join('');
|
|
1226
|
+
if (script?.dataset.fontUrl)
|
|
1227
|
+
fonts.textContent += `@font-face{font-family:'lab comments';font-display:swap;src:url('${new URL(script.dataset.fontUrl, script.src || location.href)}') format('woff2');}`;
|
|
1228
|
+
const fontName = script?.dataset.fontUrl ? 'lab comments' : script?.dataset.font;
|
|
1229
|
+
if (fontName) host.style.setProperty('--lab-font', `'${fontName.replace(/'/g, '')}', system-ui, sans-serif`);
|
|
1230
|
+
|
|
1231
|
+
// A pin-shaped cursor over everything you can comment on (links and controls keep theirs).
|
|
1232
|
+
const PIN_CURSOR =
|
|
1233
|
+
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26'%3E%3Cpath d='M3 23V12.5a9.5 9.5 0 1 1 9.5 9.5H4z' fill='black' stroke='white' stroke-width='2'/%3E%3C/svg%3E\") 3 23, crosshair";
|
|
1234
|
+
fonts.textContent += 'html.lab-dragging, html.lab-dragging * { user-select: none !important; -webkit-user-select: none !important; cursor: crosshair !important; }';
|
|
1235
|
+
fonts.textContent += `html.lab-pinning body, html.lab-pinning body *:not(${INTERACTIVE}, a *, button *, label *, [role="button"] *, [data-lab-comments]) { cursor: ${PIN_CURSOR}; }`;
|
|
1236
|
+
// Holding Option (Alt) shows the pin cursor on links and controls too, since Option-click pins there.
|
|
1237
|
+
fonts.textContent += `html.lab-alt body, html.lab-alt body *:not([data-lab-comments], [data-lab-comments] *, ${OFF}, ${OFF} *) { cursor: ${PIN_CURSOR} !important; }`;
|
|
1238
|
+
const setAlt = (down) => document.documentElement.classList.toggle('lab-alt', Boolean(down));
|
|
1239
|
+
addEventListener('keydown', (e) => e.key === 'Alt' && setAlt(true), true);
|
|
1240
|
+
addEventListener('keyup', (e) => e.key === 'Alt' && setAlt(false), true);
|
|
1241
|
+
addEventListener('pointermove', (e) => setAlt(e.altKey), { capture: true, passive: true }); // Option pressed while another window had focus
|
|
1242
|
+
addEventListener('blur', () => setAlt(false));
|
|
1243
|
+
|
|
1244
|
+
const start = () => {
|
|
1245
|
+
document.head.append(fonts);
|
|
1246
|
+
if (on) showHint();
|
|
1247
|
+
document.body.append(host);
|
|
1248
|
+
// The page's own selection while commenting, and the quoted words of an open text comment.
|
|
1249
|
+
// These rules live in the document, so the colours are read from the layer once it is in the page.
|
|
1250
|
+
const cs = getComputedStyle(host);
|
|
1251
|
+
// A soft tint of the accent, so the quoted words stay readable in the page's own colour.
|
|
1252
|
+
const paint = `background-color: color-mix(in srgb, ${cs.getPropertyValue('--_accent').trim() || '#141414'} 16%, transparent); color: inherit;`;
|
|
1253
|
+
fonts.textContent += `html.lab-pinning ::selection { ${paint} } ::highlight(lab-quote) { ${paint} }`;
|
|
1254
|
+
load();
|
|
1255
|
+
};
|
|
1256
|
+
if (document.body) start();
|
|
1257
|
+
else document.addEventListener('DOMContentLoaded', start);
|
|
1258
|
+
|
|
1259
|
+
// Local mode (local.mjs sets data-live): one event stream while the tab is visible, so many tabs don't use up
|
|
1260
|
+
// the browser's connections. The page reloads when its files change, keeping the scroll position, and the
|
|
1261
|
+
// comments refresh when someone else, like an agent, changes them. A label says which Claude session listens.
|
|
1262
|
+
if (script?.dataset.live) {
|
|
1263
|
+
const key = 'lab-scroll:' + location.pathname;
|
|
1264
|
+
try {
|
|
1265
|
+
// The scroll position and the open thread come back after a reload, so an answer shows where you left it.
|
|
1266
|
+
const kept = JSON.parse(sessionStorage.getItem(key) || 'null');
|
|
1267
|
+
if (kept !== null) {
|
|
1268
|
+
sessionStorage.removeItem(key);
|
|
1269
|
+
if (kept.open) openId = kept.open;
|
|
1270
|
+
addEventListener('load', () => scrollTo(0, +(kept.y ?? kept)));
|
|
1271
|
+
}
|
|
1272
|
+
} catch {}
|
|
1273
|
+
// Hold a reload while a comment is being started or text is typed; a focused, empty reply box doesn't count.
|
|
1274
|
+
const busy = () => draft || [...root.querySelectorAll('textarea')].some((t) => t.value.trim());
|
|
1275
|
+
const reload = () => {
|
|
1276
|
+
if (busy()) return setTimeout(reload, 500);
|
|
1277
|
+
try {
|
|
1278
|
+
sessionStorage.setItem(key, JSON.stringify({ y: scrollY, open: openId }));
|
|
1279
|
+
} catch {}
|
|
1280
|
+
location.reload();
|
|
1281
|
+
};
|
|
1282
|
+
const who = document.createElement('a');
|
|
1283
|
+
who.className = 'listener';
|
|
1284
|
+
who.hidden = true;
|
|
1285
|
+
root.append(who);
|
|
1286
|
+
let stream = null;
|
|
1287
|
+
let seen = null;
|
|
1288
|
+
// The server counts open tabs: a tab in the background still counts, and says bye when it closes, so closing
|
|
1289
|
+
// the page stops Claude listening.
|
|
1290
|
+
const tab = Math.random().toString(36).slice(2, 12);
|
|
1291
|
+
const connect = () => {
|
|
1292
|
+
if (stream || document.hidden) return;
|
|
1293
|
+
stream = new EventSource(`${script.dataset.live}?tab=${tab}`);
|
|
1294
|
+
stream.onmessage = (e) => {
|
|
1295
|
+
const v = JSON.parse(e.data);
|
|
1296
|
+
if (String(v.files) !== script.dataset.version) return reload();
|
|
1297
|
+
if (seen !== null && v.comments !== seen) load(); // load() keeps typed text: it adds replies in place
|
|
1298
|
+
seen = v.comments;
|
|
1299
|
+
// listening: a wait is open · working: Claude got comments and is on them · away: nobody will hear a comment now.
|
|
1300
|
+
const s = v.session;
|
|
1301
|
+
const mode = s ? v.mode || 'listening' : 'away';
|
|
1302
|
+
const span = (t) => Object.assign(document.createElement('span'), { textContent: t });
|
|
1303
|
+
// "Claude is working (ooze)": Claude first, the session's name after it in brackets.
|
|
1304
|
+
const tag = s && s.name !== 'Claude' ? [span(`(${s.name})`)] : [];
|
|
1305
|
+
if (mode === 'listening') who.replaceChildren(span('talking to'), 'Claude', ...tag);
|
|
1306
|
+
else if (mode === 'working') who.replaceChildren('Claude', span('is working'), ...tag);
|
|
1307
|
+
else who.replaceChildren(span('no Claude listening'));
|
|
1308
|
+
who.classList.toggle('idle', mode === 'away');
|
|
1309
|
+
if (s?.url && mode !== 'away') Object.assign(who, { href: s.url, target: '_blank' });
|
|
1310
|
+
else who.removeAttribute('href');
|
|
1311
|
+
who.title = mode === 'away' ? 'Ask Claude to run: node local.mjs wait' : s?.resume || '';
|
|
1312
|
+
who.hidden = false;
|
|
1313
|
+
};
|
|
1314
|
+
};
|
|
1315
|
+
document.addEventListener('visibilitychange', () => {
|
|
1316
|
+
if (!document.hidden) return connect();
|
|
1317
|
+
stream?.close();
|
|
1318
|
+
stream = null;
|
|
1319
|
+
});
|
|
1320
|
+
addEventListener('pagehide', () => {
|
|
1321
|
+
stream?.close();
|
|
1322
|
+
stream = null;
|
|
1323
|
+
fetch(script.dataset.live.replace(/live$/, 'bye'), { method: 'POST', keepalive: true, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tab }) }).catch(() => {});
|
|
1324
|
+
});
|
|
1325
|
+
addEventListener('pageshow', (e) => e.persisted && connect()); // back from the back-forward cache
|
|
1326
|
+
connect();
|
|
1327
|
+
}
|
|
1328
|
+
})();
|