proto-comments 0.1.0 → 0.2.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/README.md CHANGED
@@ -65,12 +65,13 @@ Either way, comments are saved to `comments/comments.json`, relative to the fold
65
65
  | --- | --- |
66
66
  | **C** | Start commenting: hover outlines an element, click it to leave a comment |
67
67
  | **V** | Stop commenting |
68
- | **Esc** | Close the open comment or History |
68
+ | **H** | Open or close History |
69
+ | **Esc** | Close the draft, the open comment or History |
69
70
  | Toolbar | Bottom center. Drag it by the dots; it stays where you put it |
70
71
 
71
72
  The shortcuts use physical keys, so they work in any keyboard layout. In text fields C types a letter.
72
73
 
73
- In a comment you can reply, resolve, mark it for the agent, or delete it. In History you can filter (Open, For Agent, Resolved, All), select several comments and **Mark for Agent**, or delete everything.
74
+ In a comment you can reply, resolve, mark it for the agent, or delete it. In History you can filter (All, Open, For Agent, Resolved). Only open comments are numbered, and they come first in History. A resolved comment shows a check and who resolved it, so the next new comment can be #1 again. Each row has the same actions as an open comment, and you can select several comments to resolve, mark for the agent or delete them at once.
74
75
 
75
76
  ## Screens in single-URL prototypes
76
77
 
@@ -110,7 +111,7 @@ export default function Review() {
110
111
  | `getContext` | none | `() => string` shown in History, e.g. `"new client · admin"` |
111
112
  | `headingSelector` | none | Extra selectors for block titles, e.g. `'.card-title'`. Built-in: `h1`–`h6`, `legend`, `[class*="title"]`, `label` |
112
113
 
113
- **Better anchors.** A pin is found by a CSS path from the nearest ancestor with an `id` or `data-cid`, plus the element's text. Add `data-cid="…"` to key containers if your markup has few ids.
114
+ **Better anchors.** A pin is found by a CSS path from the nearest ancestor with an `id` or `data-cid`, plus the element's text. Add `data-cid="…"` to key containers if your markup has few ids. If the element can't be found any more, the comment has no pin, but it still opens from History.
114
115
 
115
116
  **Theming.** Colors are CSS variables on `.pc-root` (`--pc-primary`, `--pc-dark`, `--pc-agent`, …). Override them in your own CSS.
116
117
 
@@ -126,7 +127,7 @@ npx proto-comments resolve 3 "Shortened the subtitle to one sentence"
126
127
  npx proto-comments reply 4 "Should this apply to the popup too?"
127
128
  ```
128
129
 
129
- Options: `--file <path>`, `--author <name>` (default `Agent`).
130
+ Options: `--file <path>`, `--author <name>` (default `Agent`). A comment or reply can be up to 5000 characters.
130
131
 
131
132
  The page doesn't poll. It reloads comments when its tab becomes active again, so the agent's replies show up when you come back.
132
133
 
@@ -153,6 +154,8 @@ Comments on the prototype live in `comments/comments.json` (proto-comments).
153
154
  }
154
155
  ```
155
156
 
157
+ A resolved comment also has `resolvedBy` (the `author` prop, or `--author` in the CLI) and `resolvedAt`. Reopening clears them.
158
+
156
159
  ## License
157
160
 
158
161
  MIT
@@ -26,7 +26,7 @@ function parse(argv) {
26
26
  }
27
27
 
28
28
  const place = a => [...new Set([a.block, a.text].filter(Boolean))].join(' › ') || `<${a.tag}>`;
29
- const line = c => `#${c.n} ${c.status}${c.queued ? ' [For Agent]' : ''} · ${c.screenLabel} · ${place(c.anchor)}\n ${c.text}`;
29
+ const line = c => `${c.status === 'resolved' ? '✓' : `#${c.n}`} ${c.status}${c.resolvedBy ? ` by ${c.resolvedBy}` : ''}${c.queued ? ' [For Agent]' : ''} · ${c.screenLabel} · ${place(c.anchor)}\n ${c.text}`;
30
30
  /** Marked "For Agent" if any are marked, otherwise every open comment. */
31
31
  const todo = list => { const open = list.filter(c => c.status === 'open'); const q = open.filter(c => c.queued); return q.length ? q : open; };
32
32
 
@@ -62,7 +62,8 @@ async function main() {
62
62
  const opts = parse(process.argv.slice(2));
63
63
  const [cmd, n, ...words] = opts._;
64
64
  const storage = createStorage(opts.file);
65
- const find = list => list.find(c => c.n === Number(n)) || (console.error(`No comment #${n}`), process.exit(1));
65
+ /* Resolved comments can share a number with an open one; the open one wins. */
66
+ const find = list => list.find(c => c.n === Number(n) && c.status === 'open') || list.findLast(c => c.n === Number(n)) || (console.error(`No comment #${n}`), process.exit(1));
66
67
 
67
68
  if (cmd === 'serve') return serve(storage, Number(opts.port) || 4848);
68
69
  const list = await storage.read();
@@ -78,8 +79,10 @@ async function main() {
78
79
  }
79
80
  if (cmd === 'resolve' || cmd === 'reply') {
80
81
  const c = find(list);
81
- c.replies.push({ author: opts.author || 'Agent', text: words.join(' ') || 'Done.', createdAt: new Date().toISOString() });
82
- if (cmd === 'resolve') { c.status = 'resolved'; c.queued = false; }
82
+ const author = opts.author || 'Agent';
83
+ const at = new Date().toISOString();
84
+ c.replies.push({ author, text: words.join(' ') || 'Done.', createdAt: at });
85
+ if (cmd === 'resolve') Object.assign(c, { status: 'resolved', queued: false, resolvedBy: author, resolvedAt: at });
83
86
  await storage.write(list);
84
87
  return console.log(line(c));
85
88
  }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  // src/index.jsx
4
- import { useEffect as useEffect2, useState as useState2 } from "react";
4
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
 
7
7
  // src/store.js
@@ -9,7 +9,17 @@ import { useSyncExternalStore } from "react";
9
9
 
10
10
  // src/anchors.js
11
11
  var ROOT_SEL = (el) => el.id ? `#${CSS.escape(el.id)}` : el.dataset?.cid ? `[data-cid="${el.dataset.cid}"]` : null;
12
- var textOf = (el) => (el.innerText || el.getAttribute?.("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 60);
12
+ var segmenter = typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter() : null;
13
+ var cut = (s, n) => {
14
+ if (s.length <= n || !segmenter) return s.slice(0, n);
15
+ let out = "";
16
+ for (const { segment } of segmenter.segment(s)) {
17
+ if (out.length + segment.length > n) break;
18
+ out += segment;
19
+ }
20
+ return out;
21
+ };
22
+ var textOf = (el) => cut((el.innerText || el.getAttribute?.("aria-label") || "").replace(/\s+/g, " ").trim(), 60);
13
23
  function selectorFor(el) {
14
24
  const parts = [];
15
25
  for (let n = el; n && n !== document.body; n = n.parentElement) {
@@ -57,14 +67,14 @@ function findAnchor(a) {
57
67
  }
58
68
  if (el && (!a.text || textOf(el) === a.text)) return el;
59
69
  if (a.text) {
60
- const match = [...document.querySelectorAll(a.tag)].find((n) => textOf(n) === a.text && !n.closest(".pc-root"));
61
- if (match) return match;
70
+ const same = [...document.querySelectorAll(a.tag)].filter((n) => !n.closest(".pc-root") && textOf(n) === a.text);
71
+ if (same.length === 1) return same[0];
62
72
  }
63
73
  return el;
64
74
  }
65
75
 
66
76
  // src/store.js
67
- var state = { list: [], mode: false, historyOpen: false, openId: null, draft: null, flashId: null, filter: "open" };
77
+ var state = { list: [], mode: false, historyOpen: false, openId: null, draft: null, flashId: null, bornId: null, filter: "all" };
68
78
  var subs = /* @__PURE__ */ new Set();
69
79
  var getC = () => state;
70
80
  var setC = (patch) => {
@@ -112,10 +122,13 @@ async function loadComments() {
112
122
  async function addComment(anchor, text) {
113
123
  const screen = cfg.getScreen();
114
124
  const c = await api("POST", { screen: screen.key, screenLabel: screen.label, context: cfg.getContext(), snapshot: cfg.getSnapshot(), anchor, text, author: cfg.author });
115
- setC({ list: [...state.list, c], draft: null });
125
+ setC({ list: [...state.list, c], draft: null, bornId: c.id });
126
+ setTimeout(() => {
127
+ if (state.bornId === c.id) setC({ bornId: null });
128
+ }, 400);
116
129
  }
117
130
  async function patchComment(id, patch) {
118
- const c = await api("PATCH", { id, ...patch });
131
+ const c = await api("PATCH", { id, author: cfg.author, ...patch });
119
132
  setC({ list: state.list.map((x) => x.id === id ? c : x) });
120
133
  }
121
134
  async function markComments(ids, queued) {
@@ -125,12 +138,9 @@ async function deleteComment(id) {
125
138
  await api("DELETE", { id });
126
139
  setC({ list: state.list.filter((x) => x.id !== id), openId: null });
127
140
  }
128
- async function deleteAllComments() {
129
- await api("DELETE", { all: true });
130
- setC({ list: [], openId: null, draft: null });
131
- }
132
141
  function reveal(c) {
133
- findAnchor(c.anchor)?.scrollIntoView({ block: "center", behavior: "smooth" });
142
+ const smooth = !matchMedia("(prefers-reduced-motion: reduce)").matches;
143
+ findAnchor(c.anchor)?.scrollIntoView({ block: "center", behavior: smooth ? "smooth" : "auto" });
134
144
  setC({ openId: c.id, flashId: c.id });
135
145
  setTimeout(() => setC({ flashId: null }), 1600);
136
146
  }
@@ -154,37 +164,65 @@ function resumePendingGo() {
154
164
  }
155
165
 
156
166
  // src/ui.jsx
157
- import { useEffect, useRef, useState } from "react";
167
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
158
168
 
159
169
  // src/icons.jsx
160
170
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
161
171
  var PATHS = {
162
- comment: /* @__PURE__ */ jsx("path", { d: "M3 4.5A1.5 1.5 0 0 1 4.5 3h7A1.5 1.5 0 0 1 13 4.5v5a1.5 1.5 0 0 1-1.5 1.5H7l-3 2.5V11h0a1 1 0 0 1-1-1z" }),
163
- list: /* @__PURE__ */ jsxs(Fragment, { children: [
164
- /* @__PURE__ */ jsx("path", { d: "M6 4.5h7M6 8h7M6 11.5h7" }),
165
- /* @__PURE__ */ jsx("path", { d: "M3 4.5h.01M3 8h.01M3 11.5h.01" })
172
+ comment: /* @__PURE__ */ jsx("path", { d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" }),
173
+ history: /* @__PURE__ */ jsxs(Fragment, { children: [
174
+ /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
175
+ /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" }),
176
+ /* @__PURE__ */ jsx("path", { d: "M12 7v5l4 2" })
177
+ ] }),
178
+ x: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }),
179
+ check: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" }),
180
+ reopen: /* @__PURE__ */ jsxs(Fragment, { children: [
181
+ /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
182
+ /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" })
166
183
  ] }),
167
- x: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }),
184
+ agent: /* @__PURE__ */ jsx("path", { d: "M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" }),
168
185
  trash: /* @__PURE__ */ jsxs(Fragment, { children: [
169
- /* @__PURE__ */ jsx("path", { d: "M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.5 8.5h6l.5-8.5" }),
170
- /* @__PURE__ */ jsx("path", { d: "M7 7v3.5M9 7v3.5" })
186
+ /* @__PURE__ */ jsx("path", { d: "M10 11v6M14 11v6M3 6h18" }),
187
+ /* @__PURE__ */ jsx("path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }),
188
+ /* @__PURE__ */ jsx("path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" })
171
189
  ] })
172
190
  };
173
191
  function Icon({ name }) {
174
- return /* @__PURE__ */ jsx("svg", { className: "pc-i", viewBox: "0 0 16 16", width: "16", height: "16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: PATHS[name] });
192
+ return /* @__PURE__ */ jsx("svg", { className: "pc-i", viewBox: "0 0 24 24", width: "16", height: "16", fill: "none", stroke: "currentColor", strokeWidth: "2.25", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: PATHS[name] });
175
193
  }
176
194
  function Grip() {
177
- return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 16 16", width: "16", height: "16", fill: "currentColor", "aria-hidden": "true", children: [4, 8, 12].map((y) => [6, 10].map((x) => /* @__PURE__ */ jsx("circle", { cx: x, cy: y, r: "1.3" }, `${x}${y}`))) });
195
+ return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", width: "16", height: "16", fill: "currentColor", "aria-hidden": "true", children: [5, 12, 19].map((y) => [9, 15].map((x) => /* @__PURE__ */ jsx("circle", { cx: x, cy: y, r: "1.75" }, `${x}${y}`))) });
178
196
  }
179
197
 
180
198
  // src/ui.jsx
181
199
  import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
182
- var when = (iso) => new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
183
- var popStyle = (x, y) => ({
184
- left: Math.max(8, Math.min(x + 18, window.innerWidth - 328)),
185
- top: Math.max(8, Math.min(y - 8, window.innerHeight - 260))
186
- });
187
- function Field({ placeholder, submit, onCancel, label }) {
200
+ var nameOf = (a) => a.text || a.block || `<${a.tag}>`;
201
+ var when = (iso) => {
202
+ const d = new Date(iso);
203
+ if (Number.isNaN(d.getTime())) return "";
204
+ const year = d.getFullYear() === (/* @__PURE__ */ new Date()).getFullYear() ? void 0 : "numeric";
205
+ return d.toLocaleString("en-US", { year, month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
206
+ };
207
+ var byline = (author, iso) => [author, when(iso)].filter(Boolean).join(" \xB7 ");
208
+ function usePop(x, y, lost) {
209
+ const ref = useRef(null);
210
+ const [h, setH] = useState(0);
211
+ useLayoutEffect(() => {
212
+ const el = ref.current;
213
+ setH(el.offsetHeight);
214
+ const ro = new ResizeObserver(() => setH(el.offsetHeight));
215
+ ro.observe(el);
216
+ return () => ro.disconnect();
217
+ }, []);
218
+ const w = Math.min(320, window.innerWidth - 16);
219
+ const left = Math.max(8, Math.min(x + 18, window.innerWidth - w - 8));
220
+ const top = Math.max(8, Math.min(y - 8, window.innerHeight - h - 8));
221
+ return { ref, style: { left, top, transformOrigin: lost ? "center top" : `${x - left}px ${y - top}px` } };
222
+ }
223
+ var reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
224
+ var MAX_TEXT = 5e3;
225
+ function Field({ placeholder, submit, onCancel, label, bare }) {
188
226
  const [text, setText] = useState("");
189
227
  const [busy, setBusy] = useState(false);
190
228
  const go = async () => {
@@ -204,6 +242,7 @@ function Field({ placeholder, submit, onCancel, label }) {
204
242
  className: "pc-input",
205
243
  autoFocus: true,
206
244
  rows: 3,
245
+ maxLength: MAX_TEXT,
207
246
  placeholder,
208
247
  value: text,
209
248
  onChange: (e) => setText(e.target.value),
@@ -215,71 +254,71 @@ function Field({ placeholder, submit, onCancel, label }) {
215
254
  }
216
255
  }
217
256
  ),
218
- /* @__PURE__ */ jsxs2("div", { className: "pc-actions", children: [
257
+ !bare && /* @__PURE__ */ jsxs2("div", { className: "pc-actions", children: [
219
258
  onCancel && /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: onCancel, children: "Cancel" }),
220
259
  /* @__PURE__ */ jsx2("button", { className: "pc-btn primary", disabled: !text.trim() || busy, onClick: go, children: label })
221
260
  ] })
222
261
  ] });
223
262
  }
224
263
  function Draft({ draft }) {
225
- return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", style: popStyle(draft.x, draft.y), children: [
226
- /* @__PURE__ */ jsx2("div", { className: "pc-target", title: placeOf(draft.anchor), children: placeOf(draft.anchor) }),
227
- /* @__PURE__ */ jsx2(Field, { placeholder: "Leave a comment", label: "Comment", submit: (text) => addComment(draft.anchor, text), onCancel: () => setC({ draft: null }) })
264
+ const pop = usePop(draft.x, draft.y);
265
+ return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", ref: pop.ref, style: pop.style, children: [
266
+ /* @__PURE__ */ jsx2("div", { className: "pc-head", children: /* @__PURE__ */ jsx2("b", { "data-tip": placeOf(draft.anchor), children: nameOf(draft.anchor) }) }),
267
+ /* @__PURE__ */ jsx2("div", { className: "pc-pane", children: /* @__PURE__ */ jsx2(Field, { placeholder: "Leave a comment", label: "Comment", submit: (text) => addComment(draft.anchor, text), onCancel: () => setC({ draft: null }) }) })
228
268
  ] });
229
269
  }
270
+ var resolvedOf = (c) => ({ status: c.status === "resolved" ? "open" : "resolved" });
271
+ var toggleResolved = (c) => patchComment(c.id, resolvedOf(c));
272
+ var toggleQueued = (c) => markComments([c.id], !c.queued);
230
273
  function Thread({ c, x, y, lost }) {
231
274
  const resolved = c.status === "resolved";
232
- return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", style: popStyle(x, y), children: [
275
+ const pop = usePop(x, y, lost);
276
+ const msgs = useRef(null);
277
+ useLayoutEffect(() => {
278
+ msgs.current.scrollTop = msgs.current.scrollHeight;
279
+ }, [c.replies.length]);
280
+ return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", ref: pop.ref, style: pop.style, children: [
233
281
  /* @__PURE__ */ jsxs2("div", { className: "pc-head", children: [
234
- /* @__PURE__ */ jsxs2("b", { children: [
235
- "#",
236
- c.n
237
- ] }),
238
- /* @__PURE__ */ jsx2("span", { children: c.screenLabel }),
282
+ /* @__PURE__ */ jsx2("b", { "data-tip": placeOf(c.anchor), children: nameOf(c.anchor) }),
239
283
  c.queued && /* @__PURE__ */ jsx2("i", { className: "pc-tag", children: "For Agent" }),
240
- /* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ openId: null }), "aria-label": "Close", title: "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
284
+ /* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-trash", onClick: () => deleteComment(c.id), "aria-label": "Delete comment", "data-tip": "Delete", children: /* @__PURE__ */ jsx2(Icon, { name: "trash" }) }),
285
+ /* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ openId: null }), "aria-label": "Close", "data-tip": "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
241
286
  ] }),
242
- /* @__PURE__ */ jsx2("div", { className: "pc-target", title: placeOf(c.anchor), children: placeOf(c.anchor) }),
243
- lost && /* @__PURE__ */ jsx2("div", { className: "pc-lost", children: "The element isn't on this screen any more." }),
244
- /* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
245
- /* @__PURE__ */ jsxs2("div", { className: "pc-meta", children: [
246
- c.author,
247
- " \xB7 ",
248
- when(c.createdAt)
287
+ /* @__PURE__ */ jsxs2("div", { className: "pc-pane msgs", ref: msgs, children: [
288
+ lost && /* @__PURE__ */ jsx2("div", { className: "pc-lost", children: "The element isn't on this screen any more." }),
289
+ /* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
290
+ /* @__PURE__ */ jsx2("div", { className: "pc-meta", children: byline(c.author, c.createdAt) }),
291
+ c.text
249
292
  ] }),
250
- c.text
293
+ c.replies.map((r, i) => /* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
294
+ /* @__PURE__ */ jsx2("div", { className: "pc-meta", children: byline(r.author, r.createdAt) }),
295
+ r.text
296
+ ] }, i)),
297
+ resolved && c.resolvedBy && /* @__PURE__ */ jsx2("div", { className: "pc-meta", children: ["Resolved by " + c.resolvedBy, when(c.resolvedAt)].filter(Boolean).join(" \xB7 ") })
251
298
  ] }),
252
- c.replies.map((r, i) => /* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
253
- /* @__PURE__ */ jsxs2("div", { className: "pc-meta", children: [
254
- r.author,
255
- " \xB7 ",
256
- when(r.createdAt)
257
- ] }),
258
- r.text
259
- ] }, i)),
260
- /* @__PURE__ */ jsx2(Field, { placeholder: "Reply", label: "Reply", submit: (text) => patchComment(c.id, { reply: { author: getConfig().author, text } }) }),
261
- /* @__PURE__ */ jsxs2("div", { className: "pc-actions start", children: [
262
- /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => patchComment(c.id, { status: resolved ? "open" : "resolved" }), children: resolved ? "Reopen" : "Resolve" }),
263
- !resolved && /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => markComments([c.id], !c.queued), children: c.queued ? "Unmark" : "Mark for Agent" }),
264
- /* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-trash", onClick: () => deleteComment(c.id), "aria-label": "Delete comment", title: "Delete", children: /* @__PURE__ */ jsx2(Icon, { name: "trash" }) })
299
+ /* @__PURE__ */ jsx2("div", { className: "pc-pane", children: /* @__PURE__ */ jsx2(Field, { bare: true, placeholder: "Add a comment", submit: (text) => patchComment(c.id, { reply: { author: getConfig().author, text } }) }) }),
300
+ /* @__PURE__ */ jsxs2("div", { className: "pc-actions foot", children: [
301
+ /* @__PURE__ */ jsx2("button", { className: "pc-btn sm tertiary", onClick: () => toggleResolved(c), children: resolved ? "Reopen" : "Resolve" }),
302
+ !resolved && /* @__PURE__ */ jsx2("button", { className: "pc-btn sm primary", onClick: () => toggleQueued(c), children: c.queued ? "Unmark" : "Mark for Agent" })
265
303
  ] })
266
304
  ] });
267
305
  }
268
306
  function Pin({ c, x, y }) {
269
307
  const open = useC((s) => s.openId === c.id);
270
308
  const flash = useC((s) => s.flashId === c.id);
309
+ const born = useC((s) => s.bornId === c.id);
271
310
  return /* @__PURE__ */ jsx2(
272
311
  "button",
273
312
  {
274
- className: `pc-pin${c.status === "resolved" ? " done" : ""}${c.queued ? " queued" : ""}${open ? " on" : ""}${flash ? " flash" : ""}`,
313
+ className: `pc-pin${c.status === "resolved" ? " done" : ""}${c.queued ? " queued" : ""}${open ? " on" : ""}${flash ? " flash" : ""}${born ? " born" : ""}`,
275
314
  style: { left: x, top: y },
276
315
  onClick: () => setC({ openId: open ? null : c.id, draft: null }),
277
- "aria-label": `Comment ${c.n}`,
278
- children: c.n
316
+ "aria-label": c.status === "resolved" ? "Resolved comment" : `Comment ${c.n}`,
317
+ children: c.status === "resolved" ? /* @__PURE__ */ jsx2(Icon, { name: "check" }) : c.n
279
318
  }
280
319
  );
281
320
  }
282
- var FILTERS = [["open", "Open"], ["queued", "For Agent"], ["resolved", "Resolved"], ["all", "All"]];
321
+ var FILTERS = [["all", "All"], ["open", "Open"], ["queued", "For Agent"], ["resolved", "Resolved"]];
283
322
  var matches = (c, f) => f === "all" || (f === "queued" ? c.queued && c.status === "open" : c.status === f);
284
323
  var groupBy = (items, key) => items.reduce((m, it) => {
285
324
  var _a;
@@ -287,72 +326,91 @@ var groupBy = (items, key) => items.reduce((m, it) => {
287
326
  return m;
288
327
  }, {});
289
328
  function History() {
329
+ const isOpen = useC((s) => s.historyOpen);
290
330
  const list = useC((s) => s.list);
291
331
  const filter = useC((s) => s.filter);
292
- const shown = list.filter((c) => matches(c, filter));
332
+ const shown = list.filter((c) => matches(c, filter)).sort((a, b) => (a.status === "resolved") - (b.status === "resolved"));
293
333
  const groups = Object.entries(groupBy(shown, (c) => c.screenLabel));
294
334
  const count = (f) => list.filter((c) => matches(c, f)).length;
295
- const [confirm, setConfirm] = useState(false);
296
335
  const [picked, setPicked] = useState([]);
297
- const selectable = shown.filter((c) => c.status === "open").map((c) => c.id);
336
+ const selectable = shown.map((c) => c.id);
298
337
  const sel = picked.filter((id) => selectable.includes(id));
299
338
  const allPicked = selectable.length > 0 && sel.length === selectable.length;
300
339
  const toggle = (id) => setPicked((p) => p.includes(id) ? p.filter((x) => x !== id) : [...p, id]);
301
- const anyQueued = sel.some((id) => list.find((c) => c.id === id)?.queued);
302
- const mark = (queued) => markComments(sel, queued).then(() => setPicked([]));
303
- return /* @__PURE__ */ jsxs2("aside", { className: "pc-history", "aria-label": "Comments", children: [
340
+ useEffect(() => {
341
+ if (!isOpen) setPicked([]);
342
+ }, [isOpen]);
343
+ const [leaving, setLeaving] = useState([]);
344
+ const leave = async (items, next, action) => {
345
+ const ids = items.filter((c) => !next || !matches({ ...c, ...next(c) }, filter)).map((c) => c.id);
346
+ if (ids.length) {
347
+ setLeaving((l) => [...l, ...ids]);
348
+ if (!reducedMotion()) await new Promise((res) => setTimeout(res, 150));
349
+ }
350
+ try {
351
+ await action();
352
+ } finally {
353
+ setLeaving((l) => l.filter((id) => !ids.includes(id)));
354
+ }
355
+ };
356
+ const picks = shown.filter((c) => sel.includes(c.id));
357
+ const open = picks.filter((c) => c.status === "open");
358
+ const allResolved = picks.length > 0 && !open.length;
359
+ const allQueued = open.length > 0 && open.every((c) => c.queued);
360
+ const done = () => setPicked([]);
361
+ const setStatus = (status) => leave(picks, () => ({ status }), async () => {
362
+ for (const c of picks) if (c.status !== status) await patchComment(c.id, { status });
363
+ done();
364
+ });
365
+ const mark = () => leave(open, () => ({ queued: !allQueued }), () => markComments(open.map((c) => c.id), !allQueued).then(done));
366
+ const remove = () => leave(picks, null, async () => {
367
+ for (const c of picks) await deleteComment(c.id);
368
+ done();
369
+ });
370
+ return /* @__PURE__ */ jsxs2("aside", { className: "pc-history", "data-open": isOpen || void 0, "aria-label": "Comments", children: [
304
371
  /* @__PURE__ */ jsxs2("header", { children: [
305
372
  /* @__PURE__ */ jsx2("b", { children: "Comments" }),
306
- list.length > 0 && /* @__PURE__ */ jsx2("button", { className: "pc-btn ghost danger", onClick: () => setConfirm(true), children: "Delete all" }),
307
- /* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ historyOpen: false }), "aria-label": "Close", title: "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
308
- ] }),
309
- confirm && /* @__PURE__ */ jsxs2("div", { className: "pc-confirm", children: [
310
- /* @__PURE__ */ jsxs2("span", { children: [
311
- "Delete all ",
312
- list.length,
313
- " comments, open and resolved? This can't be undone."
314
- ] }),
315
- /* @__PURE__ */ jsxs2("div", { className: "pc-actions", children: [
316
- /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => setConfirm(false), children: "Cancel" }),
317
- /* @__PURE__ */ jsx2("button", { className: "pc-btn danger solid", onClick: () => deleteAllComments().then(() => setConfirm(false)), children: "Delete all" })
318
- ] })
373
+ /* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ historyOpen: false }), "aria-label": "Close", "data-tip": "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
319
374
  ] }),
320
- /* @__PURE__ */ jsx2("div", { className: "pc-chips", children: FILTERS.map(([f, l]) => /* @__PURE__ */ jsxs2("button", { className: `pc-chip${filter === f ? " on" : ""}`, onClick: () => setC({ filter: f }), children: [
375
+ /* @__PURE__ */ jsx2("div", { className: "pc-tabs", role: "tablist", children: FILTERS.map(([f, l]) => /* @__PURE__ */ jsxs2("button", { className: "pc-tab", role: "tab", "aria-selected": filter === f, onClick: () => setC({ filter: f }), children: [
321
376
  l,
322
- " ",
323
- count(f)
377
+ /* @__PURE__ */ jsx2("span", { children: count(f) })
324
378
  ] }, f)) }),
325
379
  selectable.length > 0 && /* @__PURE__ */ jsxs2("div", { className: "pc-selbar", children: [
326
380
  /* @__PURE__ */ jsxs2("label", { className: "pc-check", children: [
327
381
  /* @__PURE__ */ jsx2("input", { type: "checkbox", checked: allPicked, onChange: () => setPicked(allPicked ? [] : selectable) }),
328
382
  sel.length ? `${sel.length} selected` : "Select all"
329
383
  ] }),
330
- sel.length > 0 && anyQueued && /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: () => mark(false), children: "Unmark" }),
331
- sel.length > 0 && /* @__PURE__ */ jsxs2("button", { className: "pc-btn primary", onClick: () => mark(true), children: [
332
- "Mark for Agent (",
333
- sel.length,
334
- ")"
384
+ sel.length > 0 && /* @__PURE__ */ jsxs2("span", { className: "pc-bulk", children: [
385
+ /* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setStatus(allResolved ? "open" : "resolved"), "aria-label": allResolved ? "Reopen" : "Resolve", "data-tip": allResolved ? "Reopen" : "Resolve", children: /* @__PURE__ */ jsx2(Icon, { name: allResolved ? "reopen" : "check" }) }),
386
+ open.length > 0 && /* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-agent", "aria-pressed": allQueued, onClick: mark, "aria-label": allQueued ? "Unmark" : "Mark for Agent", "data-tip": allQueued ? "Unmark" : "Mark for Agent", children: /* @__PURE__ */ jsx2(Icon, { name: "agent" }) }),
387
+ /* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-trash", onClick: remove, "aria-label": "Delete selected", "data-tip": "Delete", children: /* @__PURE__ */ jsx2(Icon, { name: "trash" }) })
335
388
  ] })
336
389
  ] }),
337
390
  /* @__PURE__ */ jsxs2("div", { className: "pc-groups", children: [
338
391
  !shown.length && /* @__PURE__ */ jsx2("p", { className: "pc-empty", children: "No comments here. Press C and click an element to leave one." }),
339
- groups.map(([label, items]) => /* @__PURE__ */ jsxs2("section", { children: [
340
- /* @__PURE__ */ jsx2("h5", { children: label }),
341
- items.map((c) => /* @__PURE__ */ jsxs2("div", { className: `pc-row${c.status === "resolved" ? " done" : ""}`, children: [
342
- c.status === "open" ? /* @__PURE__ */ jsx2("input", { type: "checkbox", className: "pc-pick", checked: sel.includes(c.id), onChange: () => toggle(c.id), "aria-label": `Select comment ${c.n}` }) : /* @__PURE__ */ jsx2("span", { className: "pc-pick" }),
343
- /* @__PURE__ */ jsxs2("button", { className: "pc-item", onClick: () => goToComment(c), children: [
344
- /* @__PURE__ */ jsx2("span", { className: `pc-n${c.queued ? " queued" : ""}`, children: c.n }),
345
- /* @__PURE__ */ jsxs2("span", { className: "pc-body", children: [
346
- /* @__PURE__ */ jsxs2("span", { className: "pc-place", children: [
347
- c.queued && /* @__PURE__ */ jsx2("i", { className: "pc-tag", children: "For Agent" }),
348
- placeOf(c.anchor)
349
- ] }),
350
- /* @__PURE__ */ jsx2("span", { className: "pc-text", children: c.text }),
351
- /* @__PURE__ */ jsx2("span", { className: "pc-meta", children: [c.context, when(c.createdAt), c.replies.length && `${c.replies.length} ${c.replies.length > 1 ? "replies" : "reply"}`].filter(Boolean).join(" \xB7 ") })
392
+ groups.map(([label, items]) => /* @__PURE__ */ jsx2("section", { children: items.map((c) => /* @__PURE__ */ jsxs2("div", { className: `pc-row${c.status === "resolved" ? " done" : ""}${leaving.includes(c.id) ? " leaving" : ""}`, children: [
393
+ /* @__PURE__ */ jsx2("input", { type: "checkbox", className: "pc-pick", checked: sel.includes(c.id), onChange: () => toggle(c.id), "aria-label": `Select comment ${c.n}` }),
394
+ /* @__PURE__ */ jsxs2("button", { className: "pc-item", onClick: () => goToComment(c), children: [
395
+ /* @__PURE__ */ jsx2("span", { className: `pc-n${c.queued ? " queued" : ""}`, children: c.status === "resolved" ? /* @__PURE__ */ jsx2(Icon, { name: "check" }) : c.n }),
396
+ /* @__PURE__ */ jsxs2("span", { className: "pc-body", children: [
397
+ /* @__PURE__ */ jsx2("span", { className: "pc-text", children: c.text }),
398
+ /* @__PURE__ */ jsxs2("span", { className: "pc-meta", "data-tip": placeOf(c.anchor), children: [
399
+ c.queued && /* @__PURE__ */ jsx2("i", { className: "pc-tag", children: "For Agent" }),
400
+ /* @__PURE__ */ jsx2("span", { children: [nameOf(c.anchor), c.context, when(c.createdAt), c.replies.length && `${c.replies.length + 1} comments`].filter(Boolean).join(" \xB7 ") })
401
+ ] }),
402
+ c.status === "resolved" && c.resolvedBy && /* @__PURE__ */ jsxs2("span", { className: "pc-meta", children: [
403
+ "Resolved by ",
404
+ c.resolvedBy
352
405
  ] })
353
406
  ] })
354
- ] }, c.id))
355
- ] }, label))
407
+ ] }),
408
+ /* @__PURE__ */ jsxs2("span", { className: "pc-row-actions", children: [
409
+ /* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => leave([c], resolvedOf, () => toggleResolved(c)), "aria-label": c.status === "resolved" ? "Reopen" : "Resolve", "data-tip": c.status === "resolved" ? "Reopen" : "Resolve", children: /* @__PURE__ */ jsx2(Icon, { name: c.status === "resolved" ? "reopen" : "check" }) }),
410
+ c.status === "open" && /* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-agent", "aria-pressed": !!c.queued, onClick: () => leave([c], (x) => ({ queued: !x.queued }), () => toggleQueued(c)), "aria-label": c.queued ? "Unmark" : "Mark for Agent", "data-tip": c.queued ? "Unmark" : "Mark for Agent", children: /* @__PURE__ */ jsx2(Icon, { name: "agent" }) }),
411
+ /* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-trash", onClick: () => leave([c], null, () => deleteComment(c.id)), "aria-label": "Delete comment", "data-tip": "Delete", children: /* @__PURE__ */ jsx2(Icon, { name: "trash" }) })
412
+ ] })
413
+ ] }, c.id)) }, label))
356
414
  ] })
357
415
  ] });
358
416
  }
@@ -390,40 +448,132 @@ function Toolbar() {
390
448
  } catch {
391
449
  }
392
450
  };
393
- return /* @__PURE__ */ jsxs2("div", { className: "pc-bar", style: pos ? { left: pos.x, top: pos.y, bottom: "auto", transform: "none" } : void 0, role: "toolbar", "aria-label": "Comments", children: [
394
- /* @__PURE__ */ jsx2("span", { className: "pc-grip", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, "aria-hidden": "true", title: "Drag", children: /* @__PURE__ */ jsx2(Grip, {}) }),
395
- /* @__PURE__ */ jsxs2("button", { "aria-pressed": mode, onClick: () => setC({ mode: !getC().mode, draft: null, openId: null }), title: mode ? "Stop commenting (V)" : "Comment (C)", children: [
451
+ return /* @__PURE__ */ jsxs2("div", { className: "pc-bar", "data-tips": pos && pos.y < 48 ? "below" : void 0, style: pos ? { left: pos.x, top: pos.y, bottom: "auto", transform: "none" } : void 0, role: "toolbar", "aria-label": "Comments", children: [
452
+ /* @__PURE__ */ jsx2("span", { className: "pc-grip", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, "aria-hidden": "true", children: /* @__PURE__ */ jsx2(Grip, {}) }),
453
+ /* @__PURE__ */ jsxs2("button", { "aria-pressed": mode, onClick: () => setC({ mode: !getC().mode, draft: null, openId: null }), "aria-label": mode ? "Stop commenting" : "Comment", "aria-keyshortcuts": mode ? "V" : "C", children: [
396
454
  /* @__PURE__ */ jsx2(Icon, { name: "comment" }),
397
- /* @__PURE__ */ jsx2("span", { children: "Comment" }),
398
- /* @__PURE__ */ jsx2("kbd", { children: mode ? "V" : "C" })
455
+ /* @__PURE__ */ jsxs2("span", { className: "pc-tip", "aria-hidden": "true", children: [
456
+ mode ? "Stop commenting" : "Comment",
457
+ /* @__PURE__ */ jsx2("kbd", { children: mode ? "V" : "C" })
458
+ ] })
399
459
  ] }),
400
- /* @__PURE__ */ jsxs2("button", { "aria-pressed": historyOpen, onClick: () => setC({ historyOpen: !historyOpen }), title: "All comments", children: [
401
- /* @__PURE__ */ jsx2(Icon, { name: "list" }),
402
- /* @__PURE__ */ jsx2("span", { children: "History" }),
403
- open > 0 && /* @__PURE__ */ jsx2("b", { children: open })
460
+ /* @__PURE__ */ jsxs2("button", { "aria-pressed": historyOpen, onClick: () => setC({ historyOpen: !historyOpen }), "aria-label": open ? `History, ${open} open` : "History", "aria-keyshortcuts": "H", children: [
461
+ /* @__PURE__ */ jsx2(Icon, { name: "history" }),
462
+ open > 0 && /* @__PURE__ */ jsx2("i", { className: "pc-dot" }),
463
+ /* @__PURE__ */ jsxs2("span", { className: "pc-tip", "aria-hidden": "true", children: [
464
+ open ? `History \xB7 ${open} open` : "History",
465
+ /* @__PURE__ */ jsx2("kbd", { children: "H" })
466
+ ] })
404
467
  ] })
405
468
  ] });
406
469
  }
470
+ var TIP_DELAY = 300;
471
+ function Tooltip() {
472
+ const [tip, setTip] = useState(null);
473
+ const ref = useRef(null);
474
+ useEffect(() => {
475
+ if (!matchMedia("(hover: hover)").matches) return;
476
+ let el = null, timer, hiddenAt = 0;
477
+ const hide = () => {
478
+ clearTimeout(timer);
479
+ if (el) hiddenAt = Date.now();
480
+ el = null;
481
+ setTip(null);
482
+ };
483
+ const over = (e) => {
484
+ const next = e.target.closest?.(".pc-root [data-tip]");
485
+ if (next === el) return;
486
+ if (!next) return hide();
487
+ const warm = el || Date.now() - hiddenAt < TIP_DELAY;
488
+ clearTimeout(timer);
489
+ el = next;
490
+ timer = setTimeout(() => setTip({ text: next.dataset.tip, rect: next.getBoundingClientRect() }), warm ? 0 : TIP_DELAY);
491
+ };
492
+ document.addEventListener("pointerover", over);
493
+ document.addEventListener("pointerdown", hide, true);
494
+ window.addEventListener("scroll", hide, true);
495
+ return () => {
496
+ clearTimeout(timer);
497
+ document.removeEventListener("pointerover", over);
498
+ document.removeEventListener("pointerdown", hide, true);
499
+ window.removeEventListener("scroll", hide, true);
500
+ };
501
+ }, []);
502
+ useLayoutEffect(() => {
503
+ const node = ref.current;
504
+ if (!tip || !node) return;
505
+ const { rect } = tip;
506
+ const half = node.offsetWidth / 2;
507
+ const below = rect.top - node.offsetHeight - 10 < 8;
508
+ node.style.left = `${Math.max(8 + half, Math.min(rect.left + rect.width / 2, window.innerWidth - 8 - half))}px`;
509
+ if (below) {
510
+ node.dataset.side = "below";
511
+ node.style.top = `${rect.bottom + 10}px`;
512
+ } else node.style.bottom = `${window.innerHeight - rect.top + 10}px`;
513
+ node.getBoundingClientRect();
514
+ node.setAttribute("data-open", "");
515
+ }, [tip]);
516
+ return tip && /* @__PURE__ */ jsx2("span", { ref, className: "pc-tip float", role: "tooltip", children: tip.text }, tip.text + tip.rect.x + tip.rect.y);
517
+ }
407
518
 
408
519
  // src/styles.js
409
520
  var styles_default = `
410
521
  .pc-root{
411
- --pc-dark:#262A3D; --pc-dark-hover:#30354B; --pc-dark-text:#C5CAD8; --pc-dark-kbd:#3A3F57;
412
- --pc-primary:#3366DD; --pc-primary-hover:#2A56C0;
413
- --pc-surface:#FFFFFF; --pc-surface-2:#F6F7F9; --pc-surface-3:#EEF0F4;
414
- --pc-border:#E2E5EB; --pc-border-strong:#CDD2DB;
415
- --pc-text:#3B4152; --pc-text-strong:#2B3040; --pc-muted:#7A8194; --pc-faint:#A3A9B8;
416
- --pc-danger:#C93C3C; --pc-danger-soft:#FDEDED; --pc-warn:#A86A00; --pc-warn-soft:#FFF3DC;
417
- --pc-agent:#6B5BD6; --pc-agent-soft:#EFEDFC;
418
- --pc-shadow:0 12px 32px rgba(30,35,55,.16), 0 2px 6px rgba(30,35,55,.08);
522
+ /* surfaces */
523
+ --pc-popover:#FFFFFF; /* component-on-sunken: Popover */
524
+ --pc-container:#FFFFFF; /* container: Drawer */
525
+ --pc-field:#FFFFFF; /* component-on-container-strong: Textarea, secondary Button, active Tab */
526
+ --pc-field-hover:#F9F9F9;
527
+ --pc-component:#0000000f; /* component: Tabs track, Badge, Kbd */
528
+ --pc-hover:#0000000f; /* transparent-hover */
529
+ --pc-active:#0000001f;
530
+ --pc-row-hover:#F0F0F0; /* --pc-hover over the container, as a solid */
531
+ --pc-dark:#202020; /* standout: toolbar, Tooltip */
532
+ --pc-inverted:#000000f2; --pc-inverted-hover:#000000cc; --pc-inverted-active:#000000b3; --pc-on-inverted:#FFFFFF;
533
+ /* text */
534
+ --pc-text-strong:#000000df; /* primary-foreground */
535
+ --pc-text:#0000009b; /* secondary-foreground */
536
+ --pc-faint:#00000072; /* tertiary-foreground */
537
+ --pc-disabled:#00000044;
538
+ /* lines */
539
+ --pc-border:#0000000f; /* border-default */
540
+ --pc-border-strong:#00000026;
541
+ --pc-tab-border:#D9D9D9; /* gray-6: Tabs indicator */
542
+ /* accents */
543
+ --pc-primary:#0090FF; --pc-primary-hover:#0588F0; --pc-primary-soft:#008FF519; --pc-primary-text:#0D74CE;
544
+ --pc-focus:#0080FF8c; /* brand-border-focus */
545
+ --pc-danger:#C12400cc; --pc-danger-solid:#E54D2E; --pc-danger-solid-hover:#DD4425; --pc-danger-solid-active:#D13415;
546
+ --pc-danger-soft:#FF200008; --pc-danger-soft-hover:#F52B0018; --pc-danger-soft-active:#FF35003a;
547
+ --pc-warn:#CC4E00; --pc-warn-soft:#FF9C0026;
548
+ --pc-agent:#6E56CF; --pc-agent-soft:#3201FF1a; --pc-agent-text:#6550B9;
549
+ --pc-done:#BBBBBB;
550
+ /* shadows: s-default, s-extra-large */
551
+ --pc-shadow-sm:0 0 0 .5px #0000000f, 0 1px 1px -1px #0000001a, 0 1px 2px 0 #0000000d;
552
+ --pc-shadow:0 0 0 .5px #0000000f, 0 8px 40px 0 #0000000d, 0 12px 32px -16px #00000026;
419
553
  --pc-z:2147483000;
420
- font:13px/1.45 Inter, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color:var(--pc-text);
421
- -webkit-font-smoothing:antialiased;
554
+ font:400 13px/20px InterVariable, Inter, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-feature-settings:"ss03";
555
+ color:var(--pc-text-strong); -webkit-font-smoothing:antialiased;
556
+ }
557
+ :root.dark .pc-root, .dark .pc-root, .pc-root.dark{
558
+ --pc-popover:#2A2A2A; --pc-container:#111111; --pc-field:#ffffff12; --pc-field-hover:#ffffff1b;
559
+ --pc-component:#ffffff12; --pc-hover:#ffffff1b; --pc-active:#ffffff22; --pc-row-hover:#282828;
560
+ --pc-dark:#222222;
561
+ --pc-inverted:#fffffff2; --pc-inverted-hover:#ffffffcc; --pc-inverted-active:#ffffffb3; --pc-on-inverted:#000000;
562
+ --pc-text-strong:#ffffffed; --pc-text:#ffffffaf; --pc-faint:#ffffff64; --pc-disabled:#ffffff55;
563
+ --pc-border:#ffffff12; --pc-border-strong:#ffffff2c; --pc-tab-border:#484848;
564
+ --pc-primary:#0090FF; --pc-primary-hover:#3B9EFF; --pc-primary-soft:#0077FF3a; --pc-primary-text:#70B8FF; --pc-focus:#3B94FFf7;
565
+ --pc-danger:#FF7A57d9; --pc-danger-solid:#E54D2E; --pc-danger-solid-hover:#EC6142; --pc-danger-solid-active:#FF977D;
566
+ --pc-danger-soft:#FF60390d; --pc-danger-soft-hover:#FF482D26; --pc-danger-soft-active:#FF6F4F4a;
567
+ --pc-warn:#FFA057; --pc-warn-soft:#FF7C0021;
568
+ --pc-agent-soft:#7A5BFF26; --pc-agent-text:#BAA7FF; --pc-done:#606060;
569
+ --pc-shadow-sm:0 0 0 .5px #ffffff12 inset;
570
+ --pc-shadow:0 0 0 .5px #ffffff12 inset, 0 8px 40px 0 #0000000d, 0 12px 32px -16px #00000026;
422
571
  }
423
572
  .pc-root *, .pc-root *::before, .pc-root *::after{ box-sizing:border-box; }
424
- .pc-root button{ font:inherit; color:inherit; cursor:pointer; }
425
- .pc-root textarea, .pc-root input{ font:inherit; color:inherit; }
426
- .pc-root :focus-visible{ outline:2px solid var(--pc-primary); outline-offset:2px; }
573
+ /* :where() keeps these resets weaker than the component classes below */
574
+ :where(.pc-root) button{ font:inherit; color:inherit; cursor:pointer; }
575
+ :where(.pc-root) :is(textarea, input){ font:inherit; color:inherit; }
576
+ .pc-root :focus-visible{ outline:2px solid var(--pc-focus); outline-offset:1px; }
427
577
  .pc-i{ flex:none; }
428
578
 
429
579
  html.pc-mode, html.pc-mode *{ cursor:crosshair !important; }
@@ -431,82 +581,149 @@ html.pc-mode .pc-root, html.pc-mode .pc-root *{ cursor:auto !important; }
431
581
  html.pc-mode .pc-root button, html.pc-mode .pc-root label{ cursor:pointer !important; }
432
582
  html.pc-mode .pc-grip{ cursor:grab !important; }
433
583
 
434
- /* buttons */
435
- .pc-btn{ display:inline-flex; align-items:center; justify-content:center; gap:6px; height:30px; padding:0 10px; border-radius:6px; border:1px solid var(--pc-border-strong); background:var(--pc-surface); color:var(--pc-text); font-weight:500; white-space:nowrap; }
436
- .pc-btn:hover{ border-color:var(--pc-muted); }
437
- .pc-btn.primary{ background:var(--pc-primary); border-color:var(--pc-primary); color:#fff; }
438
- .pc-btn.primary:hover{ background:var(--pc-primary-hover); }
439
- .pc-btn[disabled]{ opacity:.5; cursor:not-allowed; }
440
- .pc-btn.ghost{ border-color:transparent; background:transparent; padding:0 8px; }
441
- .pc-btn.danger{ color:var(--pc-danger); }
442
- .pc-btn.danger:hover{ background:var(--pc-danger-soft); }
443
- .pc-btn.danger.solid{ background:var(--pc-danger); border-color:var(--pc-danger); color:#fff; }
444
- .pc-btn.danger.solid:hover{ filter:brightness(.92); }
445
- .pc-ibtn{ width:28px; height:28px; display:inline-grid; place-items:center; border:0; border-radius:6px; background:transparent; color:var(--pc-muted); }
446
- .pc-ibtn:hover{ background:var(--pc-surface-3); color:var(--pc-text); }
447
- .pc-trash{ margin-left:auto; }
448
- .pc-trash:hover{ color:var(--pc-danger); background:var(--pc-danger-soft); }
449
- .pc-chip{ height:28px; padding:0 10px; border-radius:14px; border:1px solid var(--pc-border-strong); background:var(--pc-surface); color:var(--pc-text); }
450
- .pc-chip:hover{ border-color:var(--pc-muted); }
451
- .pc-chip.on{ background:var(--pc-dark); border-color:var(--pc-dark); color:#fff; }
452
- .pc-tag{ display:inline-flex; align-items:center; height:18px; padding:0 7px; margin-right:6px; border-radius:9px; background:var(--pc-agent-soft); color:var(--pc-agent); font-style:normal; font-size:10px; font-weight:600; letter-spacing:.02em; }
584
+ /* Button (int-btn). Default = secondary, md: 28px, 10px padding, 6px radius, 13px medium. */
585
+ .pc-btn{ --bg:var(--pc-field); --bg-hover:var(--pc-field-hover); --bg-active:var(--bg-hover); --fg:var(--pc-text-strong); --fg-hover:var(--fg);
586
+ display:inline-flex; align-items:center; justify-content:center; gap:6px; height:28px; padding:0 10px; border:0; border-radius:6px;
587
+ background:var(--bg); color:var(--fg); box-shadow:var(--pc-shadow-sm); font-size:13px; line-height:20px; font-weight:500; white-space:nowrap; user-select:none;
588
+ transition:color .15s cubic-bezier(0,0,.2,1), background-color .15s cubic-bezier(0,0,.2,1), transform .15s cubic-bezier(0,0,.2,1); }
589
+ @media (hover:hover){ .pc-btn:enabled:hover{ background:var(--bg-hover); color:var(--fg-hover); } }
590
+ .pc-btn:enabled:active{ background:var(--bg-active); transform:scale(.97); }
591
+ .pc-btn.sm{ height:24px; padding:0 8px; gap:4px; }
592
+ .pc-btn.primary{ --bg:var(--pc-inverted); --bg-hover:var(--pc-inverted-hover); --bg-active:var(--pc-inverted-active); --fg:var(--pc-on-inverted); box-shadow:none; }
593
+ .pc-btn.tertiary{ --bg:var(--pc-component); --bg-hover:var(--pc-active); box-shadow:none; }
594
+ .pc-btn.ghost{ --bg:transparent; --bg-hover:var(--pc-hover); --fg:var(--pc-text); --fg-hover:var(--pc-text-strong); box-shadow:none; }
595
+ .pc-btn:disabled{ background:var(--pc-component); color:var(--pc-disabled); box-shadow:none; cursor:not-allowed; }
596
+ /* icon-only transparent, sm: 24px */
597
+ .pc-ibtn{ width:24px; height:24px; flex:none; display:inline-grid; place-items:center; padding:0; border:0; border-radius:6px; background:transparent; color:var(--pc-text); transition:background-color .15s, color .15s, transform .15s cubic-bezier(0,0,.2,1); }
598
+ @media (hover:hover){ .pc-ibtn:hover{ background:var(--pc-hover); color:var(--pc-text-strong); } }
599
+ .pc-ibtn:active{ transform:scale(.97); }
600
+ @media (hover:hover){ .pc-trash:hover{ color:var(--pc-danger); background:var(--pc-danger-soft-hover); } }
601
+
602
+ /* Badge: 20px, 6px padding, 12px, 4px radius */
603
+ .pc-tag{ display:inline-flex; align-items:center; flex:none; height:20px; padding:0 6px; margin-right:6px; border-radius:4px; background:var(--pc-agent-soft); color:var(--pc-agent-text); font-style:normal; font-size:12px; line-height:16px; font-weight:400; user-select:none; }
604
+
605
+ /* Tabs: gray track, white indicator with a gray-6 border */
606
+ .pc-tabs{ display:inline-flex; align-self:flex-start; padding:0; border-radius:6px; background:var(--pc-component); font-weight:500; }
607
+ .pc-tab{ display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 10px; border:1px solid transparent; border-radius:6px; background:transparent; color:var(--pc-text); white-space:nowrap; transition:color .2s cubic-bezier(.4,0,.2,1); }
608
+ .pc-tab span{ color:var(--pc-faint); font-variant-numeric:tabular-nums; }
609
+ @media (hover:hover){ .pc-tab:hover{ color:var(--pc-text-strong); } }
610
+ .pc-tab[aria-selected="true"]{ background:var(--pc-field); border-color:var(--pc-tab-border); color:var(--pc-text-strong); }
611
+
612
+ /* Kbd: 16px, 0.5px border, 4px radius, 11px medium uppercase */
613
+ .pc-root kbd{ display:inline-flex; align-items:center; justify-content:center; flex:none; height:16px; min-width:16px; padding:0 3px; border:.5px solid var(--pc-border); border-radius:4px; background:var(--pc-component); color:var(--pc-text); font-family:inherit; font-size:11px; line-height:16px; font-weight:500; font-feature-settings:"ss02"; text-transform:uppercase; }
453
614
 
454
- /* toolbar */
455
- .pc-bar{ position:fixed; z-index:calc(var(--pc-z) + 4); left:50%; transform:translateX(-50%); bottom:calc(16px + env(safe-area-inset-bottom,0px)); display:flex; align-items:center; gap:2px; padding:4px; border-radius:10px; background:var(--pc-dark); color:var(--pc-dark-text); box-shadow:var(--pc-shadow); user-select:none; }
456
- .pc-bar button{ display:inline-flex; align-items:center; gap:6px; height:30px; padding:0 10px; border:0; border-radius:7px; background:transparent; color:inherit; }
457
- .pc-bar button:hover{ background:var(--pc-dark-hover); color:#fff; }
458
- .pc-bar button[aria-pressed="true"]{ background:var(--pc-primary); color:#fff; }
459
- .pc-bar kbd{ font:inherit; font-size:11px; padding:0 5px; border-radius:4px; background:var(--pc-dark-kbd); color:#D9DCE6; }
460
- .pc-bar b{ min-width:18px; height:18px; padding:0 5px; border-radius:9px; background:var(--pc-primary); color:#fff; font-size:11px; display:grid; place-items:center; }
461
- .pc-grip{ display:grid; place-items:center; width:20px; height:30px; margin-left:2px; opacity:.6; cursor:grab; touch-action:none; }
462
- .pc-grip:hover{ opacity:1; }
463
- .pc-hint{ position:fixed; z-index:calc(var(--pc-z) + 1); left:50%; bottom:calc(68px + env(safe-area-inset-bottom,0px)); transform:translateX(-50%); background:var(--pc-dark); color:#fff; padding:8px 12px; border-radius:8px; box-shadow:var(--pc-shadow); }
615
+ /* Checkbox: 14px, 1.5px border, 4px radius, brand fill when checked */
616
+ .pc-root input[type=checkbox]{ appearance:none; -webkit-appearance:none; position:relative; width:14px; height:14px; margin:0; flex:none; border:1.5px solid var(--pc-border-strong); border-radius:4px; background:transparent; background-size:60%; cursor:pointer; transition:background-color .2s cubic-bezier(.23,1,.32,1), background-size .15s cubic-bezier(.23,1,.32,1), transform .15s cubic-bezier(.23,1,.32,1); }
617
+ @media (hover:hover){ .pc-root input[type=checkbox]:hover{ background-color:var(--pc-hover); } }
618
+ .pc-root input[type=checkbox]:active{ transform:scale(.97); }
619
+ .pc-root input[type=checkbox]:checked{ border-color:transparent; background:var(--pc-primary) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14'%3E%3Cpath d='M3.5 7.2l2.3 2.3 4.7-5' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/100% no-repeat; }
620
+ @media (hover:hover){ .pc-root input[type=checkbox]:checked:hover{ background-color:var(--pc-primary-hover); } }
464
621
 
465
- /* pins and popovers */
466
- .pc-outline{ position:fixed; z-index:var(--pc-z); pointer-events:none; outline:2px solid var(--pc-primary); outline-offset:1px; border-radius:3px; background:rgba(51,102,221,.06); }
467
- .pc-outline.flash{ animation:pc-flash 1.6s ease-out forwards; }
622
+ /* Textarea: white field with s-default, 8px radius; blue ring on focus */
623
+ .pc-input{ display:block; width:100%; min-height:80px; resize:none; field-sizing:content; max-height:200px; border:0; border-radius:8px; padding:4px 8px; background:var(--pc-field); color:var(--pc-text-strong); caret-color:var(--pc-text); box-shadow:var(--pc-shadow-sm); font-size:13px; line-height:20px; transition:color .15s, background-color .15s, box-shadow .15s; }
624
+ .pc-input::placeholder{ color:var(--pc-faint); }
625
+ @media (hover:hover){ .pc-input:hover{ background:var(--pc-field-hover); } }
626
+ .pc-input:focus{ outline:none; background:var(--pc-field); box-shadow:0 0 0 1px var(--pc-focus), inset 0 0 0 .5px var(--pc-focus); }
627
+
628
+ /* toolbar: standout surface, dark like the Tooltip */
629
+ .pc-bar{ position:fixed; z-index:calc(var(--pc-z) + 4); left:50%; transform:translateX(-50%); bottom:calc(16px + env(safe-area-inset-bottom,0px)); display:flex; align-items:center; gap:2px; padding:4px; border-radius:10px; background:var(--pc-dark); color:#ffffffaf; box-shadow:0 0 0 .5px #ffffff12 inset, 0 8px 40px 0 #0000000d, 0 12px 32px -16px #00000026; user-select:none; }
630
+ .pc-bar button{ position:relative; display:inline-grid; place-items:center; width:28px; height:28px; padding:0; border:0; border-radius:6px; background:transparent; color:inherit; transition:background-color .15s, color .15s, transform .15s cubic-bezier(0,0,.2,1); }
631
+ @media (hover:hover){ .pc-bar button:hover{ background:#ffffff1b; color:#ffffffed; } }
632
+ .pc-bar button:active{ transform:scale(.97); }
633
+ .pc-bar button[aria-pressed="true"]{ background:#ffffff22; color:#ffffffed; box-shadow:0 0 0 .5px #ffffff12 inset; }
634
+ .pc-bar kbd{ border-color:#ffffff12; background:#ffffff12; color:#ffffffaf; }
635
+ /* open comments: a dot on the History icon */
636
+ .pc-dot{ position:absolute; top:5px; right:5px; width:6px; height:6px; border-radius:50%; background:var(--pc-primary); box-shadow:0 0 0 1.5px var(--pc-dark); }
637
+ /* Tooltip (DS): standout surface, 8px radius, 12px medium; shows after a short delay, hides at once */
638
+ .pc-tip{ position:absolute; left:50%; bottom:calc(100% + 10px); display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:8px; background:var(--pc-dark); color:#ffffffed; box-shadow:0 0 0 .5px #ffffff12 inset; font-size:12px; line-height:16px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transform:translate(-50%, 4px) scale(.95); transform-origin:50% 100%; transition:opacity .15s, transform .15s cubic-bezier(.23,1,.32,1); }
639
+ .pc-bar[data-tips="below"] .pc-tip{ bottom:auto; top:calc(100% + 10px); transform:translate(-50%, -4px) scale(.95); transform-origin:50% 0; }
640
+ .pc-bar button:hover .pc-tip, .pc-bar button:focus-visible .pc-tip{ opacity:1; transform:translate(-50%, 0) scale(1); transition-duration:.2s; transition-delay:.3s; }
641
+ @media (hover:none){ .pc-tip{ display:none; } }
642
+ /* The same Tooltip for [data-tip] controls: <Tooltip> places it by the control, so a scrolling panel can't clip it */
643
+ .pc-tip.float{ position:fixed; z-index:calc(var(--pc-z) + 5); bottom:auto; }
644
+ .pc-tip.float[data-side="below"]{ transform:translate(-50%, -4px) scale(.95); transform-origin:50% 0; }
645
+ .pc-tip.float[data-open]{ opacity:1; transform:translate(-50%, 0) scale(1); transition-duration:.2s; }
646
+ .pc-grip{ display:grid; place-items:center; width:20px; height:28px; opacity:.5; cursor:grab; touch-action:none; transition:opacity .15s; }
647
+ @media (hover:hover){ .pc-grip:hover{ opacity:1; } }
648
+ /* Tooltip */
649
+ .pc-hint{ position:fixed; z-index:calc(var(--pc-z) + 1); left:50%; bottom:calc(60px + env(safe-area-inset-bottom,0px)); transform:translateX(-50%); padding:6px 10px; border-radius:8px; background:var(--pc-dark); color:#ffffffed; box-shadow:0 0 0 .5px #ffffff12 inset; font-size:12px; line-height:16px; font-weight:500; white-space:nowrap; animation:pc-hint-in .15s cubic-bezier(0,0,.2,1); }
650
+ /* translate (the property) composes with the centering transform above */
651
+ @keyframes pc-hint-in{ from{ opacity:0; translate:0 4px; } }
652
+
653
+ /* pins */
654
+ .pc-outline{ position:fixed; z-index:var(--pc-z); pointer-events:none; outline:1.5px solid var(--pc-primary); outline-offset:1px; border-radius:4px; background:var(--pc-primary-soft); }
655
+ .pc-outline.flash{ animation:pc-flash 1.6s cubic-bezier(0,0,.2,1) forwards; }
468
656
  @keyframes pc-flash{ 0%,60%{ opacity:1 } 100%{ opacity:0 } }
469
- .pc-pin{ position:fixed; z-index:calc(var(--pc-z) + 1); transform:translate(-2px,-100%); min-width:26px; height:26px; padding:0 7px; border:2px solid #fff; border-radius:13px 13px 13px 2px; background:var(--pc-primary); color:#fff !important; font-size:12px !important; font-weight:600; box-shadow:var(--pc-shadow); }
470
- .pc-pin.on{ background:var(--pc-dark); }
657
+ .pc-pin{ position:fixed; z-index:calc(var(--pc-z) + 1); transform:translate(-2px,-100%); min-width:24px; height:24px; padding:0 7px; border:2px solid #fff; border-radius:12px 12px 12px 2px; background:var(--pc-primary); color:#fff !important; font-size:12px !important; font-weight:500; font-variant-numeric:tabular-nums; box-shadow:var(--pc-shadow); transform-origin:left bottom; }
658
+ .pc-pin.on{ background:#202020; z-index:calc(var(--pc-z) + 2); }
471
659
  .pc-pin.queued, .pc-n.queued{ background:var(--pc-agent); }
472
- .pc-pin.done{ background:var(--pc-faint); }
473
- .pc-pin.flash{ animation:pc-pin .5s ease-out 2; }
474
- @keyframes pc-pin{ 50%{ transform:translate(-2px,-100%) scale(1.25) } }
475
- .pc-pop{ position:fixed; z-index:calc(var(--pc-z) + 2); width:320px; padding:12px; display:flex; flex-direction:column; gap:10px; background:var(--pc-surface); border:1px solid var(--pc-border); border-radius:8px; box-shadow:var(--pc-shadow); max-height:calc(100vh - 16px); overflow:auto; }
476
- .pc-head{ display:flex; align-items:center; gap:8px; }
477
- .pc-head span{ flex:1; color:var(--pc-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
478
- .pc-head .pc-tag{ margin:0; }
479
- .pc-target, .pc-place{ font-size:12px; color:var(--pc-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
480
- .pc-head + .pc-target{ margin-top:-4px; }
481
- .pc-input{ width:100%; resize:vertical; border:1px solid var(--pc-border-strong); border-radius:6px; padding:8px 10px; font-size:14px !important; background:var(--pc-surface); }
482
- .pc-input:focus{ outline:none; border-color:var(--pc-primary); }
660
+ .pc-pin.done{ background:var(--pc-done); padding:0; width:24px; display:grid; place-items:center; }
661
+ .pc-pin .pc-i, .pc-n .pc-i{ width:12px; height:12px; }
662
+ .pc-pin.flash{ animation:pc-pin .5s cubic-bezier(.23,1,.32,1) 2; }
663
+ @keyframes pc-pin{ 50%{ transform:translate(-2px,-100%) scale(1.15) } }
664
+ /* a pin that was just left: grows out of its tail, where the draft popover was */
665
+ .pc-pin.born{ animation:pc-pin-in .2s cubic-bezier(.32,.72,0,1); }
666
+ @keyframes pc-pin-in{ from{ opacity:0; transform:translate(-2px,-100%) scale(.8) } }
667
+
668
+ /* Popover: 10px radius, s-extra-large, 0.5px dividers */
669
+ .pc-pop{ position:fixed; z-index:calc(var(--pc-z) + 5); width:min(320px, calc(100vw - 16px)); padding:0; display:flex; flex-direction:column; background:var(--pc-popover); color:var(--pc-text-strong); border-radius:10px; box-shadow:var(--pc-shadow); max-height:calc(100vh - 16px); overflow:hidden; animation:pc-pop-in .18s cubic-bezier(.32,.72,0,1); }
670
+ /* transform-origin is set inline, at the pin or click point */
671
+ @keyframes pc-pop-in{ from{ opacity:0; transform:scale(.97) } }
672
+ .pc-head{ flex:none; display:flex; align-items:center; gap:2px; min-height:40px; padding:8px 8px 8px 12px; border-bottom:.5px solid var(--pc-border); }
673
+ .pc-head b{ flex:1; min-width:0; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
674
+ .pc-head .pc-tag{ margin:0 6px; }
675
+ /* header, reply field and actions stay put; only the messages scroll */
676
+ .pc-pane{ flex:none; display:flex; flex-direction:column; gap:10px; padding:12px; }
677
+ .pc-pane.msgs{ flex:0 1 auto; min-height:48px; overflow:auto; overscroll-behavior:contain; padding-bottom:0; }
678
+ .pc-pane.msgs + .pc-pane{ padding-top:10px; }
483
679
  .pc-actions{ display:flex; justify-content:flex-end; gap:8px; }
484
- .pc-actions.start{ justify-content:flex-start; border-top:1px solid var(--pc-border); padding-top:10px; }
485
- .pc-msg{ font-size:14px; color:var(--pc-text-strong); white-space:pre-wrap; }
486
- .pc-meta{ display:block; font-size:12px; color:var(--pc-muted); margin-bottom:2px; }
487
- .pc-lost{ font-size:12px; color:var(--pc-warn); background:var(--pc-warn-soft); border-radius:6px; padding:6px 8px; }
680
+ .pc-actions.foot{ flex:none; padding:6px 8px; border-top:.5px solid var(--pc-border); }
681
+ .pc-msg{ white-space:pre-wrap; overflow-wrap:anywhere; }
682
+ .pc-meta{ display:block; font-size:12px; line-height:16px; color:var(--pc-faint); margin-bottom:2px; }
683
+ .pc-lost{ font-size:12px; line-height:16px; color:var(--pc-warn); background:var(--pc-warn-soft); border-radius:6px; padding:6px 8px; }
488
684
 
489
- /* history */
490
- .pc-history{ position:fixed; z-index:calc(var(--pc-z) + 3); top:0; right:0; bottom:0; width:360px; max-width:100%; background:var(--pc-surface); border-left:1px solid var(--pc-border); box-shadow:var(--pc-shadow); display:flex; flex-direction:column; }
491
- .pc-history header{ display:flex; align-items:center; gap:4px; padding:12px 12px 8px 16px; font-size:15px; }
492
- .pc-history header b{ flex:1; color:var(--pc-text-strong); }
493
- .pc-confirm{ margin:0 16px 12px; padding:10px 12px; border-radius:8px; background:var(--pc-danger-soft); color:var(--pc-text-strong); display:flex; flex-direction:column; gap:8px; }
494
- .pc-chips{ display:flex; flex-wrap:wrap; gap:6px; padding:0 16px 12px; border-bottom:1px solid var(--pc-border); }
495
- .pc-selbar{ display:flex; align-items:center; gap:8px; min-height:44px; padding:6px 16px; border-bottom:1px solid var(--pc-border); }
496
- .pc-check{ flex:1; display:inline-flex; align-items:center; gap:10px; cursor:pointer; }
497
- .pc-check input, .pc-pick{ width:16px; height:16px; margin:0; accent-color:var(--pc-primary); flex:none; }
498
- .pc-groups{ flex:1; overflow:auto; padding:8px 0 16px; }
499
- .pc-groups h5{ margin:12px 16px 4px; font-size:12px; font-weight:600; color:var(--pc-muted); text-transform:uppercase; letter-spacing:.04em; }
500
- .pc-row{ display:flex; align-items:flex-start; padding-left:16px; }
501
- .pc-row:hover{ background:var(--pc-surface-2); }
502
- .pc-pick{ margin-top:11px; }
503
- .pc-item{ flex:1; min-width:0; display:flex; gap:10px; padding:8px 16px 8px 10px; border:0; background:transparent; text-align:left; }
504
- .pc-n{ flex:none; min-width:22px; height:22px; padding:0 6px; border-radius:11px 11px 11px 2px; background:var(--pc-primary); color:#fff; font-size:11px; font-weight:600; display:grid; place-items:center; }
505
- .pc-row.done .pc-n{ background:var(--pc-faint); }
506
- .pc-body{ min-width:0; display:flex; flex-direction:column; gap:2px; }
507
- .pc-text{ color:var(--pc-text-strong); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
508
- .pc-row.done .pc-text{ color:var(--pc-muted); }
509
- .pc-empty{ margin:16px; color:var(--pc-muted); }
685
+ /* History: right Drawer */
686
+ .pc-history{ position:fixed; z-index:calc(var(--pc-z) + 3); top:0; right:0; bottom:0; width:384px; max-width:100%; background:var(--pc-container); color:var(--pc-text-strong); border-left:.5px solid var(--pc-border); box-shadow:var(--pc-shadow); display:flex; flex-direction:column;
687
+ transform:translateX(100%); visibility:hidden; transition:transform .18s cubic-bezier(.32,.72,0,1), visibility 0s .18s; }
688
+ /* a transition, not keyframes: toggling History mid-slide reverses it smoothly. Closing is faster than opening */
689
+ .pc-history[data-open]{ transform:none; visibility:visible; transition:transform .25s cubic-bezier(.32,.72,0,1); }
690
+ .pc-history header{ display:flex; align-items:center; gap:6px; padding:16px 16px 12px 20px; }
691
+ .pc-history header b{ flex:1; font-size:18px; line-height:24px; font-weight:500; }
692
+ .pc-history > .pc-tabs{ display:flex; align-self:stretch; margin:0 20px 12px; }
693
+ /* narrow drawer or 4-digit counts: tabs shrink instead of overflowing */
694
+ .pc-history > .pc-tabs .pc-tab{ flex:1 1 auto; min-width:0; justify-content:center; gap:4px; padding:0 6px; overflow:hidden; }
695
+ .pc-selbar{ display:flex; align-items:center; gap:6px; min-height:40px; padding:6px 16px 6px 20px; border-top:.5px solid var(--pc-border); border-bottom:.5px solid var(--pc-border); }
696
+ .pc-check{ flex:1; display:inline-flex; align-items:center; gap:8px; cursor:pointer; color:var(--pc-text); font-weight:500; user-select:none; }
697
+ .pc-check:has(:checked){ color:var(--pc-text-strong); }
698
+ .pc-bulk{ display:flex; gap:2px; margin-right:-4px; }
699
+ /* Command list: 6px-radius items */
700
+ /* the bottom padding keeps the last row clear of the toolbar when the drawer is full width */
701
+ .pc-groups{ flex:1; overflow:auto; padding:8px 12px 72px; }
702
+ /* row: checkbox, pin and the first text line share one 20px line */
703
+ .pc-row{ position:relative; display:flex; align-items:flex-start; gap:10px; padding:8px; border-radius:6px; transition:background-color .15s, opacity .15s cubic-bezier(.4,0,1,1); }
704
+ /* fades before it leaves the filtered list */
705
+ .pc-row.leaving{ opacity:0; pointer-events:none; }
706
+ @media (hover:hover){ .pc-row:hover{ background:var(--pc-hover); } }
707
+ .pc-root .pc-row .pc-pick{ width:14px; height:14px; flex:none; margin:3px 0 0; }
708
+ .pc-item{ flex:1; min-width:0; display:flex; align-items:flex-start; gap:10px; padding:0; border:0; background:transparent; text-align:left; }
709
+ .pc-n{ flex:none; min-width:20px; height:20px; padding:0 5px; border-radius:10px 10px 10px 2px; background:var(--pc-primary); color:#fff; font-size:11px; font-weight:500; font-variant-numeric:tabular-nums; display:grid; place-items:center; }
710
+ /* row actions: the thread's Resolve, Mark for Agent and Delete; shown on hover or focus, always on touch */
711
+ /* overlaid at the row's right edge so hidden actions take no width; the fade matches the row's hover fill */
712
+ .pc-row-actions{ position:absolute; top:6px; right:6px; display:flex; gap:2px; padding-left:20px; border-radius:6px; background:linear-gradient(to right, transparent, var(--pc-row-hover) 20px); opacity:0; pointer-events:none; transition:opacity .15s; }
713
+ .pc-row:hover .pc-row-actions, .pc-row:focus-within .pc-row-actions{ opacity:1; pointer-events:auto; }
714
+ /* on touch they are always there, so they take their own column instead of covering the text */
715
+ @media (hover:none){ .pc-row-actions{ position:static; flex:none; opacity:1; pointer-events:auto; padding-left:0; background:none; margin:-2px -2px 0 0; } }
716
+ .pc-agent[aria-pressed="true"]{ color:var(--pc-agent-text); background:var(--pc-agent-soft); }
717
+ .pc-row.done .pc-n{ background:var(--pc-done); padding:0; width:20px; }
718
+ .pc-body{ flex:1; min-width:0; display:flex; flex-direction:column; gap:4px; }
719
+ .pc-text{ font-weight:450; overflow-wrap:anywhere; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
720
+ .pc-row.done .pc-text{ color:var(--pc-faint); }
721
+ .pc-body .pc-meta{ display:flex; align-items:center; gap:6px; min-width:0; margin:0; }
722
+ .pc-body .pc-meta > span{ min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
723
+ .pc-body .pc-tag{ margin:0; }
724
+ .pc-empty{ margin:8px; color:var(--pc-faint); }
725
+
726
+ @media (prefers-reduced-motion:reduce){ .pc-root *{ transition:none !important; animation:none !important; } }
510
727
  `;
511
728
 
512
729
  // src/index.jsx
@@ -528,20 +745,77 @@ function useStyles() {
528
745
  document.head.appendChild(s);
529
746
  }, []);
530
747
  }
748
+ var clippers = (el) => {
749
+ const out = [];
750
+ for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
751
+ const s = getComputedStyle(n);
752
+ if (s.overflowX !== "visible" || s.overflowY !== "visible") out.push(n);
753
+ }
754
+ return out;
755
+ };
756
+ var inside = (x, y, els) => els.every((n) => {
757
+ const r = n.getBoundingClientRect();
758
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
759
+ });
760
+ var PIN_H = 24;
761
+ var pinWidth = (c) => c.status === "resolved" ? 24 : Math.max(24, 18 + 7 * String(c.n).length);
762
+ function place(list, pos) {
763
+ const placed = [];
764
+ list.filter((c) => pos[c.id]).sort((a, b) => a.n - b.n).forEach((c) => {
765
+ const p = pos[c.id], w = pinWidth(c);
766
+ p.x = Math.max(4, Math.min(p.x, window.innerWidth - w - 2));
767
+ p.y = Math.max(PIN_H + 4, Math.min(p.y, window.innerHeight - 4));
768
+ const startX = p.x;
769
+ for (let moved = true; moved; ) {
770
+ moved = false;
771
+ for (const q of placed) {
772
+ if (Math.abs(p.y - q.y) < PIN_H && p.x < q.x + q.w + 2 && p.x + w + 2 > q.x) {
773
+ p.x = q.x + q.w + 2;
774
+ moved = true;
775
+ }
776
+ }
777
+ if (p.x + w > window.innerWidth - 2) {
778
+ p.x = startX;
779
+ p.y += PIN_H + 2;
780
+ moved = true;
781
+ }
782
+ }
783
+ placed.push({ x: p.x, y: p.y, w });
784
+ });
785
+ }
531
786
  function useFrame(list, openId, flashId) {
532
787
  const [frame, setFrame] = useState2({ screen: null, pos: {} });
533
788
  useEffect2(() => {
534
- let raf, last = "";
789
+ let raf, last = "", lastScreen = null, dirty = true, found = {};
790
+ const mo = new MutationObserver((records) => {
791
+ if (records.some((r) => !ours(r.target.nodeType === 1 ? r.target : r.target.parentElement))) dirty = true;
792
+ });
793
+ mo.observe(document.body, { childList: true, characterData: true, subtree: true });
794
+ const timer = setInterval(() => {
795
+ dirty = true;
796
+ }, 1e3);
535
797
  const tick = () => {
536
798
  const screen = getConfig().getScreen().key;
799
+ const shown = list.filter((c) => c.screen === screen && (c.status === "open" || c.id === openId));
800
+ if (dirty || screen !== lastScreen) {
801
+ found = {};
802
+ shown.forEach((c) => {
803
+ const el = findAnchor(c.anchor);
804
+ if (el) found[c.id] = { el, clip: clippers(el) };
805
+ });
806
+ dirty = false;
807
+ lastScreen = screen;
808
+ }
537
809
  const pos = {};
538
- list.forEach((c) => {
539
- if (c.screen !== screen || c.status !== "open" && c.id !== openId) return;
540
- const el = findAnchor(c.anchor);
541
- if (!el || !el.getClientRects().length) return;
542
- const r = rectOf(el);
543
- pos[c.id] = { x: r.left + c.anchor.offset.x * r.width, y: r.top + c.anchor.offset.y * r.height, rect: c.id === flashId ? r : null };
810
+ shown.forEach((c) => {
811
+ const f = found[c.id];
812
+ if (!f || !f.el.isConnected || !f.el.getClientRects().length) return;
813
+ const r = rectOf(f.el);
814
+ const x = r.left + c.anchor.offset.x * r.width, y = r.top + c.anchor.offset.y * r.height;
815
+ if (c.id !== openId && !inside(x, y, f.clip)) return;
816
+ pos[c.id] = { x, y, rect: c.id === flashId ? r : null };
544
817
  });
818
+ place(shown, pos);
545
819
  const key = JSON.stringify([screen, pos]);
546
820
  if (key !== last) {
547
821
  last = key;
@@ -550,7 +824,11 @@ function useFrame(list, openId, flashId) {
550
824
  raf = requestAnimationFrame(tick);
551
825
  };
552
826
  tick();
553
- return () => cancelAnimationFrame(raf);
827
+ return () => {
828
+ cancelAnimationFrame(raf);
829
+ clearInterval(timer);
830
+ mo.disconnect();
831
+ };
554
832
  }, [list, openId, flashId]);
555
833
  return frame;
556
834
  }
@@ -566,7 +844,7 @@ function ProtoComments(props) {
566
844
  setRoot(el);
567
845
  return () => el.remove();
568
846
  }, []);
569
- const { list, mode, draft, openId, historyOpen, flashId } = useC((s) => s);
847
+ const { list, mode, draft, openId, flashId } = useC((s) => s);
570
848
  const [hover, setHover] = useState2(null);
571
849
  useEffect2(() => {
572
850
  const load = () => {
@@ -597,6 +875,7 @@ function ProtoComments(props) {
597
875
  setC({ mode: false, draft: null });
598
876
  }
599
877
  if (e.code === "KeyC" && !c.mode && !typing(e.target)) setC({ mode: true, openId: null });
878
+ if (e.code === "KeyH" && !typing(e.target)) setC({ historyOpen: !c.historyOpen });
600
879
  };
601
880
  window.addEventListener("keydown", onKey, true);
602
881
  return () => window.removeEventListener("keydown", onKey, true);
@@ -623,7 +902,12 @@ function ProtoComments(props) {
623
902
  document.documentElement.classList.remove("pc-mode");
624
903
  };
625
904
  }, [mode, cfg2.headingSelector]);
626
- const { pos } = useFrame(list, openId, flashId);
905
+ const { screen, pos } = useFrame(list, openId, flashId);
906
+ const lastScreen = useRef2(screen);
907
+ useEffect2(() => {
908
+ if (lastScreen.current !== null && screen !== lastScreen.current) setC({ openId: null, draft: null });
909
+ lastScreen.current = screen;
910
+ }, [screen]);
627
911
  const here = list.filter((c) => pos[c.id]);
628
912
  const open = list.find((c) => c.id === openId);
629
913
  if (!root) return null;
@@ -633,9 +917,10 @@ function ProtoComments(props) {
633
917
  here.map((c) => /* @__PURE__ */ jsx3(Pin, { c, x: pos[c.id].x, y: pos[c.id].y }, c.id)),
634
918
  here.map((c) => pos[c.id].rect && /* @__PURE__ */ jsx3("div", { className: "pc-outline flash", style: pos[c.id].rect }, `f${c.id}`)),
635
919
  draft && /* @__PURE__ */ jsx3(Draft, { draft }),
636
- open && (pos[open.id] ? /* @__PURE__ */ jsx3(Thread, { c: open, x: pos[open.id].x, y: pos[open.id].y }) : /* @__PURE__ */ jsx3(Thread, { c: open, x: window.innerWidth / 2 - 160, y: 120, lost: true })),
637
- historyOpen && /* @__PURE__ */ jsx3(History, {}),
920
+ open && (pos[open.id] ? /* @__PURE__ */ jsx3(Thread, { c: open, x: pos[open.id].x, y: pos[open.id].y }, open.id) : /* @__PURE__ */ jsx3(Thread, { c: open, x: window.innerWidth / 2 - 160, y: 120, lost: true }, open.id)),
921
+ /* @__PURE__ */ jsx3(History, {}),
638
922
  /* @__PURE__ */ jsx3(Toolbar, {}),
923
+ /* @__PURE__ */ jsx3(Tooltip, {}),
639
924
  mode && /* @__PURE__ */ jsx3("div", { className: "pc-hint", children: "Click an element to comment \xB7 V to stop" })
640
925
  ] }),
641
926
  root
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "proto-comments",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Figma-style review comments on a running React prototype, split by screen even in single-URL apps, stored in a JSON file a coding agent can apply.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "keywords": ["react", "prototype", "comments", "review", "feedback", "annotations", "design", "ai-agent", "claude"],
7
+ "keywords": [
8
+ "react",
9
+ "prototype",
10
+ "comments",
11
+ "review",
12
+ "feedback",
13
+ "annotations",
14
+ "design",
15
+ "ai-agent",
16
+ "claude"
17
+ ],
8
18
  "main": "./dist/index.js",
9
19
  "module": "./dist/index.js",
10
20
  "exports": {
@@ -13,10 +23,20 @@
13
23
  "./storage": "./server/storage.js",
14
24
  "./package.json": "./package.json"
15
25
  },
16
- "bin": { "proto-comments": "./bin/proto-comments.js" },
17
- "files": ["dist", "server", "bin", "README.md", "LICENSE"],
26
+ "bin": {
27
+ "proto-comments": "bin/proto-comments.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "server",
32
+ "bin",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
18
36
  "sideEffects": false,
19
- "engines": { "node": ">=18" },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
20
40
  "scripts": {
21
41
  "build": "node build.mjs",
22
42
  "prepublishOnly": "npm run build"
package/server/storage.js CHANGED
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
 
4
4
  /* Comments live in one JSON file in the project, so a coding agent can read and resolve them directly. */
5
5
  export const DEFAULT_FILE = 'comments/comments.json';
6
+ /* Same limit as the comment field in src/ui.jsx. */
7
+ export const MAX_TEXT = 5000;
6
8
 
7
9
  export function createStorage(file = DEFAULT_FILE) {
8
10
  const abs = path.resolve(process.cwd(), file);
@@ -18,21 +20,25 @@ export function createStorage(file = DEFAULT_FILE) {
18
20
  await fs.writeFile(abs, JSON.stringify(list, null, 2) + '\n');
19
21
  }
20
22
  const now = () => new Date().toISOString();
23
+ /* Numbers count open comments only: resolved ones show a check, so a new comment after them is #1 again. */
24
+ const nextN = list => Math.max(0, ...list.filter(c => c.status === 'open').map(c => c.n)) + 1;
21
25
 
22
26
  /** One entry point for every transport (Next route, standalone server): returns { status, json }. */
23
27
  async function handle(method, body) {
24
28
  if (method === 'GET') return { status: 200, json: await read() };
29
+ const text = body?.text ?? body?.reply?.text;
30
+ if (typeof text === 'string' && text.length > MAX_TEXT) return { status: 413, json: { error: `Comment is longer than ${MAX_TEXT} characters` } };
25
31
  return locked(async () => {
26
32
  const list = await read();
27
33
  if (method === 'POST') {
28
34
  const { screen, screenLabel, context, snapshot, anchor, text, author } = body;
29
- const n = Math.max(0, ...list.map(c => c.n)) + 1;
35
+ const n = nextN(list);
30
36
  const c = { id: `c${Date.now().toString(36)}`, n, screen, screenLabel, context, anchor, text, author, status: 'open', queued: false, createdAt: now(), replies: [], snapshot };
31
37
  await write([...list, c]);
32
38
  return { status: 200, json: c };
33
39
  }
34
40
  if (method === 'PATCH') {
35
- const { id, ids, queued, status, reply } = body;
41
+ const { id, ids, queued, status, reply, author } = body;
36
42
  if (ids) {
37
43
  list.forEach(x => { if (ids.includes(x.id)) x.queued = !!queued; });
38
44
  await write(list);
@@ -40,8 +46,13 @@ export function createStorage(file = DEFAULT_FILE) {
40
46
  }
41
47
  const c = list.find(x => x.id === id);
42
48
  if (!c) return { status: 404, json: { error: 'Not found' } };
49
+ if (status === 'open' && c.status === 'resolved') {
50
+ /* Reopened: its old number may belong to a newer open comment by now. */
51
+ if (list.some(x => x.status === 'open' && x.n === c.n)) c.n = nextN(list);
52
+ delete c.resolvedBy; delete c.resolvedAt;
53
+ }
43
54
  if (status) c.status = status;
44
- if (status === 'resolved') c.queued = false;
55
+ if (status === 'resolved') Object.assign(c, { queued: false, resolvedBy: author, resolvedAt: now() });
45
56
  if (reply) c.replies.push({ ...reply, createdAt: now() });
46
57
  await write(list);
47
58
  return { status: 200, json: c };