proto-comments 0.1.0 → 0.2.1
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 +9 -6
- package/bin/proto-comments.js +7 -4
- package/dist/index.js +509 -217
- package/package.json +25 -5
- package/server/storage.js +14 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Figma-style review comments on a running React prototype.
|
|
|
4
4
|
|
|
5
5
|
- **Comments are split by screen, not only by URL.** Prototypes often show every screen on one URL. Tell the plugin what a "screen" is, and a comment left in Settings won't show up on the deal page.
|
|
6
6
|
- **Pinned to elements.** A pin follows its element when the window is resized or scrolled. Each comment records the block it's in, for example "Review before I send these invites › Emails".
|
|
7
|
-
- **History.** All comments,
|
|
7
|
+
- **History.** All comments in one list, by number, each with the screen it was left on. Click one to reopen its screen in the saved state, scroll to the element and open the thread.
|
|
8
8
|
- **Made for a coding agent.** Comments live in one JSON file in your project. Mark the ones you want, tell the agent "apply the comments", and it resolves each with a reply saying what changed.
|
|
9
9
|
|
|
10
10
|
## Install
|
|
@@ -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
|
-
| **
|
|
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,
|
|
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. In History they come first, in order of their numbers, then the resolved ones; each row shows the screen it's on. 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
|
|
|
@@ -104,13 +105,13 @@ export default function Review() {
|
|
|
104
105
|
| --- | --- | --- |
|
|
105
106
|
| `endpoint` | `'/api/comments'` | Where comments are stored |
|
|
106
107
|
| `author` | `'Reviewer'` | Name on new comments and replies |
|
|
107
|
-
| `getScreen` | URL | `() => ({ key, label })`. Comments show only on a screen with the same `key`; `label`
|
|
108
|
+
| `getScreen` | URL | `() => ({ key, label })`. Comments show only on a screen with the same `key`; `label` is the screen's name in History |
|
|
108
109
|
| `getSnapshot` | `{ url }` | JSON saved with each comment |
|
|
109
110
|
| `restoreSnapshot` | go to `url` | `(snapshot, comment) => void \| Promise`. Called from History when the comment is on another screen |
|
|
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
|
package/bin/proto-comments.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
61
|
-
if (
|
|
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: "
|
|
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
|
-
|
|
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,73 @@ 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: "
|
|
163
|
-
|
|
164
|
-
/* @__PURE__ */ jsx("path", { d: "
|
|
165
|
-
/* @__PURE__ */ jsx("path", { d: "M3
|
|
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
|
-
|
|
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: "
|
|
170
|
-
/* @__PURE__ */ jsx("path", { d: "
|
|
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
|
|
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
|
|
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
|
|
183
|
-
var
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 Clip({ as: Tag = "span", tip, children }) {
|
|
209
|
+
const ref = useRef(null);
|
|
210
|
+
useLayoutEffect(() => {
|
|
211
|
+
const el = ref.current;
|
|
212
|
+
el.classList.toggle("pc-cut", el.scrollWidth > el.clientWidth);
|
|
213
|
+
});
|
|
214
|
+
return /* @__PURE__ */ jsx2(Tag, { ref, className: "pc-clip", "data-tip": tip, children });
|
|
215
|
+
}
|
|
216
|
+
function usePop(x, y, lost) {
|
|
217
|
+
const ref = useRef(null);
|
|
218
|
+
const [h, setH] = useState(0);
|
|
219
|
+
useLayoutEffect(() => {
|
|
220
|
+
const el = ref.current;
|
|
221
|
+
setH(el.offsetHeight);
|
|
222
|
+
const ro = new ResizeObserver(() => setH(el.offsetHeight));
|
|
223
|
+
ro.observe(el);
|
|
224
|
+
return () => ro.disconnect();
|
|
225
|
+
}, []);
|
|
226
|
+
const w = Math.min(320, window.innerWidth - 16);
|
|
227
|
+
const left = Math.max(8, Math.min(x + 18, window.innerWidth - w - 8));
|
|
228
|
+
const top = Math.max(8, Math.min(y - 8, window.innerHeight - h - 8));
|
|
229
|
+
return { ref, style: { left, top, transformOrigin: lost ? "center top" : `${x - left}px ${y - top}px` } };
|
|
230
|
+
}
|
|
231
|
+
var reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
232
|
+
var MAX_TEXT = 5e3;
|
|
233
|
+
function Field({ placeholder, submit, onCancel, label, bare }) {
|
|
188
234
|
const [text, setText] = useState("");
|
|
189
235
|
const [busy, setBusy] = useState(false);
|
|
190
236
|
const go = async () => {
|
|
@@ -204,6 +250,7 @@ function Field({ placeholder, submit, onCancel, label }) {
|
|
|
204
250
|
className: "pc-input",
|
|
205
251
|
autoFocus: true,
|
|
206
252
|
rows: 3,
|
|
253
|
+
maxLength: MAX_TEXT,
|
|
207
254
|
placeholder,
|
|
208
255
|
value: text,
|
|
209
256
|
onChange: (e) => setText(e.target.value),
|
|
@@ -215,144 +262,159 @@ function Field({ placeholder, submit, onCancel, label }) {
|
|
|
215
262
|
}
|
|
216
263
|
}
|
|
217
264
|
),
|
|
218
|
-
/* @__PURE__ */ jsxs2("div", { className: "pc-actions", children: [
|
|
265
|
+
!bare && /* @__PURE__ */ jsxs2("div", { className: "pc-actions", children: [
|
|
219
266
|
onCancel && /* @__PURE__ */ jsx2("button", { className: "pc-btn", onClick: onCancel, children: "Cancel" }),
|
|
220
267
|
/* @__PURE__ */ jsx2("button", { className: "pc-btn primary", disabled: !text.trim() || busy, onClick: go, children: label })
|
|
221
268
|
] })
|
|
222
269
|
] });
|
|
223
270
|
}
|
|
224
271
|
function Draft({ draft }) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
/* @__PURE__ */ jsx2(
|
|
272
|
+
const pop = usePop(draft.x, draft.y);
|
|
273
|
+
return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", ref: pop.ref, style: pop.style, children: [
|
|
274
|
+
/* @__PURE__ */ jsx2("div", { className: "pc-head", children: /* @__PURE__ */ jsx2(Clip, { as: "b", tip: placeOf(draft.anchor), children: nameOf(draft.anchor) }) }),
|
|
275
|
+
/* @__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
276
|
] });
|
|
229
277
|
}
|
|
278
|
+
var resolvedOf = (c) => ({ status: c.status === "resolved" ? "open" : "resolved" });
|
|
279
|
+
var toggleResolved = (c) => patchComment(c.id, resolvedOf(c));
|
|
280
|
+
var toggleQueued = (c) => markComments([c.id], !c.queued);
|
|
230
281
|
function Thread({ c, x, y, lost }) {
|
|
231
282
|
const resolved = c.status === "resolved";
|
|
232
|
-
|
|
283
|
+
const pop = usePop(x, y, lost);
|
|
284
|
+
const msgs = useRef(null);
|
|
285
|
+
useLayoutEffect(() => {
|
|
286
|
+
msgs.current.scrollTop = msgs.current.scrollHeight;
|
|
287
|
+
}, [c.replies.length]);
|
|
288
|
+
return /* @__PURE__ */ jsxs2("div", { className: "pc-pop", ref: pop.ref, style: pop.style, children: [
|
|
233
289
|
/* @__PURE__ */ jsxs2("div", { className: "pc-head", children: [
|
|
234
|
-
/* @__PURE__ */
|
|
235
|
-
"#",
|
|
236
|
-
c.n
|
|
237
|
-
] }),
|
|
238
|
-
/* @__PURE__ */ jsx2("span", { children: c.screenLabel }),
|
|
290
|
+
/* @__PURE__ */ jsx2(Clip, { as: "b", tip: placeOf(c.anchor), children: nameOf(c.anchor) }),
|
|
239
291
|
c.queued && /* @__PURE__ */ jsx2("i", { className: "pc-tag", children: "For Agent" }),
|
|
240
|
-
/* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () =>
|
|
292
|
+
/* @__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" }) }),
|
|
293
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ openId: null }), "aria-label": "Close", "data-tip": "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
|
|
241
294
|
] }),
|
|
242
|
-
/* @__PURE__ */
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
c.
|
|
247
|
-
" \xB7 ",
|
|
248
|
-
when(c.createdAt)
|
|
295
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-pane msgs", ref: msgs, children: [
|
|
296
|
+
lost && /* @__PURE__ */ jsx2("div", { className: "pc-lost", children: "The element isn't on this screen any more." }),
|
|
297
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
|
|
298
|
+
/* @__PURE__ */ jsx2("div", { className: "pc-meta", children: byline(c.author, c.createdAt) }),
|
|
299
|
+
c.text
|
|
249
300
|
] }),
|
|
250
|
-
c.
|
|
301
|
+
c.replies.map((r, i) => /* @__PURE__ */ jsxs2("div", { className: "pc-msg", children: [
|
|
302
|
+
/* @__PURE__ */ jsx2("div", { className: "pc-meta", children: byline(r.author, r.createdAt) }),
|
|
303
|
+
r.text
|
|
304
|
+
] }, i)),
|
|
305
|
+
resolved && c.resolvedBy && /* @__PURE__ */ jsx2("div", { className: "pc-meta", children: ["Resolved by " + c.resolvedBy, when(c.resolvedAt)].filter(Boolean).join(" \xB7 ") })
|
|
251
306
|
] }),
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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" }) })
|
|
307
|
+
/* @__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 } }) }) }),
|
|
308
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-actions foot", children: [
|
|
309
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-btn sm tertiary", onClick: () => toggleResolved(c), children: resolved ? "Reopen" : "Resolve" }),
|
|
310
|
+
!resolved && /* @__PURE__ */ jsx2("button", { className: "pc-btn sm primary", onClick: () => toggleQueued(c), children: c.queued ? "Unmark" : "Mark for Agent" })
|
|
265
311
|
] })
|
|
266
312
|
] });
|
|
267
313
|
}
|
|
268
314
|
function Pin({ c, x, y }) {
|
|
269
315
|
const open = useC((s) => s.openId === c.id);
|
|
270
316
|
const flash = useC((s) => s.flashId === c.id);
|
|
317
|
+
const born = useC((s) => s.bornId === c.id);
|
|
271
318
|
return /* @__PURE__ */ jsx2(
|
|
272
319
|
"button",
|
|
273
320
|
{
|
|
274
|
-
className: `pc-pin${c.status === "resolved" ? " done" : ""}${c.queued ? " queued" : ""}${open ? " on" : ""}${flash ? " flash" : ""}`,
|
|
321
|
+
className: `pc-pin${c.status === "resolved" ? " done" : ""}${c.queued ? " queued" : ""}${open ? " on" : ""}${flash ? " flash" : ""}${born ? " born" : ""}`,
|
|
275
322
|
style: { left: x, top: y },
|
|
276
323
|
onClick: () => setC({ openId: open ? null : c.id, draft: null }),
|
|
277
|
-
"aria-label": `Comment ${c.n}`,
|
|
278
|
-
children: c.n
|
|
324
|
+
"aria-label": c.status === "resolved" ? "Resolved comment" : `Comment ${c.n}`,
|
|
325
|
+
children: c.status === "resolved" ? /* @__PURE__ */ jsx2(Icon, { name: "check" }) : c.n
|
|
279
326
|
}
|
|
280
327
|
);
|
|
281
328
|
}
|
|
282
|
-
var FILTERS = [["
|
|
329
|
+
var FILTERS = [["all", "All"], ["open", "Open"], ["queued", "For Agent"], ["resolved", "Resolved"]];
|
|
283
330
|
var matches = (c, f) => f === "all" || (f === "queued" ? c.queued && c.status === "open" : c.status === f);
|
|
284
|
-
var
|
|
285
|
-
var _a;
|
|
286
|
-
(m[_a = key(it)] || (m[_a] = [])).push(it);
|
|
287
|
-
return m;
|
|
288
|
-
}, {});
|
|
331
|
+
var metaOf = (c, place2) => [c.screenLabel, place2, c.context, when(c.createdAt), c.replies.length && `${c.replies.length + 1} comments`].filter(Boolean).join(" \xB7 ");
|
|
289
332
|
function History() {
|
|
333
|
+
const isOpen = useC((s) => s.historyOpen);
|
|
290
334
|
const list = useC((s) => s.list);
|
|
291
335
|
const filter = useC((s) => s.filter);
|
|
292
|
-
const
|
|
293
|
-
const
|
|
336
|
+
const rank = (c) => c.status === "resolved" ? Infinity : c.n;
|
|
337
|
+
const shown = list.filter((c) => matches(c, filter)).sort((a, b) => rank(a) - rank(b) || 0);
|
|
294
338
|
const count = (f) => list.filter((c) => matches(c, f)).length;
|
|
295
|
-
const [confirm, setConfirm] = useState(false);
|
|
296
339
|
const [picked, setPicked] = useState([]);
|
|
297
|
-
const selectable = shown.
|
|
340
|
+
const selectable = shown.map((c) => c.id);
|
|
298
341
|
const sel = picked.filter((id) => selectable.includes(id));
|
|
299
342
|
const allPicked = selectable.length > 0 && sel.length === selectable.length;
|
|
300
343
|
const toggle = (id) => setPicked((p) => p.includes(id) ? p.filter((x) => x !== id) : [...p, id]);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!isOpen) setPicked([]);
|
|
346
|
+
}, [isOpen]);
|
|
347
|
+
const [leaving, setLeaving] = useState([]);
|
|
348
|
+
const leave = async (items, next, action) => {
|
|
349
|
+
const ids = items.filter((c) => !next || !matches({ ...c, ...next(c) }, filter)).map((c) => c.id);
|
|
350
|
+
if (ids.length) {
|
|
351
|
+
setLeaving((l) => [...l, ...ids]);
|
|
352
|
+
if (!reducedMotion()) await new Promise((res) => setTimeout(res, 150));
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await action();
|
|
356
|
+
} finally {
|
|
357
|
+
setLeaving((l) => l.filter((id) => !ids.includes(id)));
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const picks = shown.filter((c) => sel.includes(c.id));
|
|
361
|
+
const open = picks.filter((c) => c.status === "open");
|
|
362
|
+
const allResolved = picks.length > 0 && !open.length;
|
|
363
|
+
const allQueued = open.length > 0 && open.every((c) => c.queued);
|
|
364
|
+
const done = () => setPicked([]);
|
|
365
|
+
const setStatus = (status) => leave(picks, () => ({ status }), async () => {
|
|
366
|
+
for (const c of picks) if (c.status !== status) await patchComment(c.id, { status });
|
|
367
|
+
done();
|
|
368
|
+
});
|
|
369
|
+
const mark = () => leave(open, () => ({ queued: !allQueued }), () => markComments(open.map((c) => c.id), !allQueued).then(done));
|
|
370
|
+
const remove = () => leave(picks, null, async () => {
|
|
371
|
+
for (const c of picks) await deleteComment(c.id);
|
|
372
|
+
done();
|
|
373
|
+
});
|
|
374
|
+
return /* @__PURE__ */ jsxs2("aside", { className: "pc-history", "data-open": isOpen || void 0, "aria-label": "Comments", children: [
|
|
304
375
|
/* @__PURE__ */ jsxs2("header", { children: [
|
|
305
376
|
/* @__PURE__ */ jsx2("b", { children: "Comments" }),
|
|
306
|
-
|
|
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
|
-
] })
|
|
377
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-ibtn", onClick: () => setC({ historyOpen: false }), "aria-label": "Close", "data-tip": "Close", children: /* @__PURE__ */ jsx2(Icon, { name: "x" }) })
|
|
319
378
|
] }),
|
|
320
|
-
/* @__PURE__ */ jsx2("div", { className: "pc-
|
|
379
|
+
/* @__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
380
|
l,
|
|
322
|
-
|
|
323
|
-
count(f)
|
|
381
|
+
/* @__PURE__ */ jsx2("span", { children: count(f) })
|
|
324
382
|
] }, f)) }),
|
|
325
383
|
selectable.length > 0 && /* @__PURE__ */ jsxs2("div", { className: "pc-selbar", children: [
|
|
326
384
|
/* @__PURE__ */ jsxs2("label", { className: "pc-check", children: [
|
|
327
385
|
/* @__PURE__ */ jsx2("input", { type: "checkbox", checked: allPicked, onChange: () => setPicked(allPicked ? [] : selectable) }),
|
|
328
386
|
sel.length ? `${sel.length} selected` : "Select all"
|
|
329
387
|
] }),
|
|
330
|
-
sel.length > 0 &&
|
|
331
|
-
|
|
332
|
-
"Mark for Agent (",
|
|
333
|
-
|
|
334
|
-
")"
|
|
388
|
+
sel.length > 0 && /* @__PURE__ */ jsxs2("span", { className: "pc-bulk", children: [
|
|
389
|
+
/* @__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" }) }),
|
|
390
|
+
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" }) }),
|
|
391
|
+
/* @__PURE__ */ jsx2("button", { className: "pc-ibtn pc-trash", onClick: remove, "aria-label": "Delete selected", "data-tip": "Delete", children: /* @__PURE__ */ jsx2(Icon, { name: "trash" }) })
|
|
335
392
|
] })
|
|
336
393
|
] }),
|
|
337
|
-
/* @__PURE__ */ jsxs2("div", { className: "pc-
|
|
394
|
+
/* @__PURE__ */ jsxs2("div", { className: "pc-list", children: [
|
|
338
395
|
!shown.length && /* @__PURE__ */ jsx2("p", { className: "pc-empty", children: "No comments here. Press C and click an element to leave one." }),
|
|
339
|
-
|
|
340
|
-
/* @__PURE__ */ jsx2("
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
/* @__PURE__ */ jsxs2("
|
|
344
|
-
/* @__PURE__ */ jsx2("span", { className:
|
|
345
|
-
/* @__PURE__ */ jsxs2("span", { className: "pc-
|
|
346
|
-
/* @__PURE__ */
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
396
|
+
shown.map((c) => /* @__PURE__ */ jsxs2("div", { className: `pc-row${c.status === "resolved" ? " done" : ""}${leaving.includes(c.id) ? " leaving" : ""}`, children: [
|
|
397
|
+
/* @__PURE__ */ jsx2("input", { type: "checkbox", className: "pc-pick", checked: sel.includes(c.id), onChange: () => toggle(c.id), "aria-label": `Select comment ${c.n}` }),
|
|
398
|
+
/* @__PURE__ */ jsxs2("button", { className: "pc-item", onClick: () => goToComment(c), children: [
|
|
399
|
+
/* @__PURE__ */ jsx2("span", { className: `pc-n${c.queued ? " queued" : ""}`, children: c.status === "resolved" ? /* @__PURE__ */ jsx2(Icon, { name: "check" }) : c.n }),
|
|
400
|
+
/* @__PURE__ */ jsxs2("span", { className: "pc-body", children: [
|
|
401
|
+
/* @__PURE__ */ jsx2("span", { className: "pc-text", children: c.text }),
|
|
402
|
+
/* @__PURE__ */ jsxs2("span", { className: "pc-meta", children: [
|
|
403
|
+
c.queued && /* @__PURE__ */ jsx2("i", { className: "pc-tag", children: "For Agent" }),
|
|
404
|
+
/* @__PURE__ */ jsx2(Clip, { tip: metaOf(c, placeOf(c.anchor)), children: metaOf(c, nameOf(c.anchor)) })
|
|
405
|
+
] }),
|
|
406
|
+
c.status === "resolved" && c.resolvedBy && /* @__PURE__ */ jsxs2("span", { className: "pc-meta", children: [
|
|
407
|
+
"Resolved by ",
|
|
408
|
+
c.resolvedBy
|
|
352
409
|
] })
|
|
353
410
|
] })
|
|
354
|
-
] },
|
|
355
|
-
|
|
411
|
+
] }),
|
|
412
|
+
/* @__PURE__ */ jsxs2("span", { className: "pc-row-actions", children: [
|
|
413
|
+
/* @__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" }) }),
|
|
414
|
+
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" }) }),
|
|
415
|
+
/* @__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" }) })
|
|
416
|
+
] })
|
|
417
|
+
] }, c.id))
|
|
356
418
|
] })
|
|
357
419
|
] });
|
|
358
420
|
}
|
|
@@ -390,40 +452,132 @@ function Toolbar() {
|
|
|
390
452
|
} catch {
|
|
391
453
|
}
|
|
392
454
|
};
|
|
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",
|
|
395
|
-
/* @__PURE__ */ jsxs2("button", { "aria-pressed": mode, onClick: () => setC({ mode: !getC().mode, draft: null, openId: null }),
|
|
455
|
+
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: [
|
|
456
|
+
/* @__PURE__ */ jsx2("span", { className: "pc-grip", onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, "aria-hidden": "true", children: /* @__PURE__ */ jsx2(Grip, {}) }),
|
|
457
|
+
/* @__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
458
|
/* @__PURE__ */ jsx2(Icon, { name: "comment" }),
|
|
397
|
-
/* @__PURE__ */
|
|
398
|
-
|
|
459
|
+
/* @__PURE__ */ jsxs2("span", { className: "pc-tip", "aria-hidden": "true", children: [
|
|
460
|
+
mode ? "Stop commenting" : "Comment",
|
|
461
|
+
/* @__PURE__ */ jsx2("kbd", { children: mode ? "V" : "C" })
|
|
462
|
+
] })
|
|
399
463
|
] }),
|
|
400
|
-
/* @__PURE__ */ jsxs2("button", { "aria-pressed": historyOpen, onClick: () => setC({ historyOpen: !historyOpen }),
|
|
401
|
-
/* @__PURE__ */ jsx2(Icon, { name: "
|
|
402
|
-
/* @__PURE__ */ jsx2("
|
|
403
|
-
|
|
464
|
+
/* @__PURE__ */ jsxs2("button", { "aria-pressed": historyOpen, onClick: () => setC({ historyOpen: !historyOpen }), "aria-label": open ? `History, ${open} open` : "History", "aria-keyshortcuts": "H", children: [
|
|
465
|
+
/* @__PURE__ */ jsx2(Icon, { name: "history" }),
|
|
466
|
+
open > 0 && /* @__PURE__ */ jsx2("i", { className: "pc-dot" }),
|
|
467
|
+
/* @__PURE__ */ jsxs2("span", { className: "pc-tip", "aria-hidden": "true", children: [
|
|
468
|
+
open ? `History \xB7 ${open} open` : "History",
|
|
469
|
+
/* @__PURE__ */ jsx2("kbd", { children: "H" })
|
|
470
|
+
] })
|
|
404
471
|
] })
|
|
405
472
|
] });
|
|
406
473
|
}
|
|
474
|
+
var TIP_DELAY = 300;
|
|
475
|
+
function Tooltip() {
|
|
476
|
+
const [tip, setTip] = useState(null);
|
|
477
|
+
const ref = useRef(null);
|
|
478
|
+
useEffect(() => {
|
|
479
|
+
if (!matchMedia("(hover: hover)").matches) return;
|
|
480
|
+
let el = null, timer, hiddenAt = 0;
|
|
481
|
+
const hide = () => {
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
if (el) hiddenAt = Date.now();
|
|
484
|
+
el = null;
|
|
485
|
+
setTip(null);
|
|
486
|
+
};
|
|
487
|
+
const over = (e) => {
|
|
488
|
+
const next = e.target.closest?.(".pc-root [data-tip]");
|
|
489
|
+
if (next === el) return;
|
|
490
|
+
if (!next) return hide();
|
|
491
|
+
const warm = el || Date.now() - hiddenAt < TIP_DELAY;
|
|
492
|
+
clearTimeout(timer);
|
|
493
|
+
el = next;
|
|
494
|
+
timer = setTimeout(() => setTip({ text: next.dataset.tip, rect: next.getBoundingClientRect() }), warm ? 0 : TIP_DELAY);
|
|
495
|
+
};
|
|
496
|
+
document.addEventListener("pointerover", over);
|
|
497
|
+
document.addEventListener("pointerdown", hide, true);
|
|
498
|
+
window.addEventListener("scroll", hide, true);
|
|
499
|
+
return () => {
|
|
500
|
+
clearTimeout(timer);
|
|
501
|
+
document.removeEventListener("pointerover", over);
|
|
502
|
+
document.removeEventListener("pointerdown", hide, true);
|
|
503
|
+
window.removeEventListener("scroll", hide, true);
|
|
504
|
+
};
|
|
505
|
+
}, []);
|
|
506
|
+
useLayoutEffect(() => {
|
|
507
|
+
const node = ref.current;
|
|
508
|
+
if (!tip || !node) return;
|
|
509
|
+
const { rect } = tip;
|
|
510
|
+
const half = node.offsetWidth / 2;
|
|
511
|
+
const below = rect.top - node.offsetHeight - 10 < 8;
|
|
512
|
+
node.style.left = `${Math.max(8 + half, Math.min(rect.left + rect.width / 2, window.innerWidth - 8 - half))}px`;
|
|
513
|
+
if (below) {
|
|
514
|
+
node.dataset.side = "below";
|
|
515
|
+
node.style.top = `${rect.bottom + 10}px`;
|
|
516
|
+
} else node.style.bottom = `${window.innerHeight - rect.top + 10}px`;
|
|
517
|
+
node.getBoundingClientRect();
|
|
518
|
+
node.setAttribute("data-open", "");
|
|
519
|
+
}, [tip]);
|
|
520
|
+
return tip && /* @__PURE__ */ jsx2("span", { ref, className: "pc-tip float", role: "tooltip", children: tip.text }, tip.text + tip.rect.x + tip.rect.y);
|
|
521
|
+
}
|
|
407
522
|
|
|
408
523
|
// src/styles.js
|
|
409
524
|
var styles_default = `
|
|
410
525
|
.pc-root{
|
|
411
|
-
|
|
412
|
-
--pc-
|
|
413
|
-
--pc-
|
|
414
|
-
--pc-
|
|
415
|
-
--pc-
|
|
416
|
-
--pc-
|
|
417
|
-
--pc-
|
|
418
|
-
--pc-
|
|
526
|
+
/* surfaces */
|
|
527
|
+
--pc-popover:#FFFFFF; /* component-on-sunken: Popover */
|
|
528
|
+
--pc-container:#FFFFFF; /* container: Drawer */
|
|
529
|
+
--pc-field:#FFFFFF; /* component-on-container-strong: Textarea, secondary Button, active Tab */
|
|
530
|
+
--pc-field-hover:#F9F9F9;
|
|
531
|
+
--pc-component:#0000000f; /* component: Tabs track, Badge, Kbd */
|
|
532
|
+
--pc-hover:#0000000f; /* transparent-hover */
|
|
533
|
+
--pc-active:#0000001f;
|
|
534
|
+
--pc-row-hover:#F0F0F0; /* --pc-hover over the container, as a solid */
|
|
535
|
+
--pc-dark:#202020; /* standout: toolbar, Tooltip */
|
|
536
|
+
--pc-inverted:#000000f2; --pc-inverted-hover:#000000cc; --pc-inverted-active:#000000b3; --pc-on-inverted:#FFFFFF;
|
|
537
|
+
/* text */
|
|
538
|
+
--pc-text-strong:#000000df; /* primary-foreground */
|
|
539
|
+
--pc-text:#0000009b; /* secondary-foreground */
|
|
540
|
+
--pc-faint:#00000072; /* tertiary-foreground */
|
|
541
|
+
--pc-disabled:#00000044;
|
|
542
|
+
/* lines */
|
|
543
|
+
--pc-border:#0000000f; /* border-default */
|
|
544
|
+
--pc-border-strong:#00000026;
|
|
545
|
+
--pc-tab-border:#D9D9D9; /* gray-6: Tabs indicator */
|
|
546
|
+
/* accents */
|
|
547
|
+
--pc-primary:#0090FF; --pc-primary-hover:#0588F0; --pc-primary-soft:#008FF519; --pc-primary-text:#0D74CE;
|
|
548
|
+
--pc-focus:#0080FF8c; /* brand-border-focus */
|
|
549
|
+
--pc-danger:#C12400cc; --pc-danger-solid:#E54D2E; --pc-danger-solid-hover:#DD4425; --pc-danger-solid-active:#D13415;
|
|
550
|
+
--pc-danger-soft:#FF200008; --pc-danger-soft-hover:#F52B0018; --pc-danger-soft-active:#FF35003a;
|
|
551
|
+
--pc-warn:#CC4E00; --pc-warn-soft:#FF9C0026;
|
|
552
|
+
--pc-agent:#6E56CF; --pc-agent-soft:#3201FF1a; --pc-agent-text:#6550B9;
|
|
553
|
+
--pc-done:#BBBBBB;
|
|
554
|
+
/* shadows: s-default, s-extra-large */
|
|
555
|
+
--pc-shadow-sm:0 0 0 .5px #0000000f, 0 1px 1px -1px #0000001a, 0 1px 2px 0 #0000000d;
|
|
556
|
+
--pc-shadow:0 0 0 .5px #0000000f, 0 8px 40px 0 #0000000d, 0 12px 32px -16px #00000026;
|
|
419
557
|
--pc-z:2147483000;
|
|
420
|
-
font:13px/
|
|
421
|
-
-webkit-font-smoothing:antialiased;
|
|
558
|
+
font:400 13px/20px InterVariable, Inter, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-feature-settings:"ss03";
|
|
559
|
+
color:var(--pc-text-strong); -webkit-font-smoothing:antialiased;
|
|
560
|
+
}
|
|
561
|
+
:root.dark .pc-root, .dark .pc-root, .pc-root.dark{
|
|
562
|
+
--pc-popover:#2A2A2A; --pc-container:#111111; --pc-field:#ffffff12; --pc-field-hover:#ffffff1b;
|
|
563
|
+
--pc-component:#ffffff12; --pc-hover:#ffffff1b; --pc-active:#ffffff22; --pc-row-hover:#282828;
|
|
564
|
+
--pc-dark:#222222;
|
|
565
|
+
--pc-inverted:#fffffff2; --pc-inverted-hover:#ffffffcc; --pc-inverted-active:#ffffffb3; --pc-on-inverted:#000000;
|
|
566
|
+
--pc-text-strong:#ffffffed; --pc-text:#ffffffaf; --pc-faint:#ffffff64; --pc-disabled:#ffffff55;
|
|
567
|
+
--pc-border:#ffffff12; --pc-border-strong:#ffffff2c; --pc-tab-border:#484848;
|
|
568
|
+
--pc-primary:#0090FF; --pc-primary-hover:#3B9EFF; --pc-primary-soft:#0077FF3a; --pc-primary-text:#70B8FF; --pc-focus:#3B94FFf7;
|
|
569
|
+
--pc-danger:#FF7A57d9; --pc-danger-solid:#E54D2E; --pc-danger-solid-hover:#EC6142; --pc-danger-solid-active:#FF977D;
|
|
570
|
+
--pc-danger-soft:#FF60390d; --pc-danger-soft-hover:#FF482D26; --pc-danger-soft-active:#FF6F4F4a;
|
|
571
|
+
--pc-warn:#FFA057; --pc-warn-soft:#FF7C0021;
|
|
572
|
+
--pc-agent-soft:#7A5BFF26; --pc-agent-text:#BAA7FF; --pc-done:#606060;
|
|
573
|
+
--pc-shadow-sm:0 0 0 .5px #ffffff12 inset;
|
|
574
|
+
--pc-shadow:0 0 0 .5px #ffffff12 inset, 0 8px 40px 0 #0000000d, 0 12px 32px -16px #00000026;
|
|
422
575
|
}
|
|
423
576
|
.pc-root *, .pc-root *::before, .pc-root *::after{ box-sizing:border-box; }
|
|
424
|
-
|
|
425
|
-
.pc-root
|
|
426
|
-
.pc-root :
|
|
577
|
+
/* :where() keeps these resets weaker than the component classes below */
|
|
578
|
+
:where(.pc-root) button{ font:inherit; color:inherit; cursor:pointer; }
|
|
579
|
+
:where(.pc-root) :is(textarea, input){ font:inherit; color:inherit; }
|
|
580
|
+
.pc-root :focus-visible{ outline:2px solid var(--pc-focus); outline-offset:1px; }
|
|
427
581
|
.pc-i{ flex:none; }
|
|
428
582
|
|
|
429
583
|
html.pc-mode, html.pc-mode *{ cursor:crosshair !important; }
|
|
@@ -431,82 +585,152 @@ html.pc-mode .pc-root, html.pc-mode .pc-root *{ cursor:auto !important; }
|
|
|
431
585
|
html.pc-mode .pc-root button, html.pc-mode .pc-root label{ cursor:pointer !important; }
|
|
432
586
|
html.pc-mode .pc-grip{ cursor:grab !important; }
|
|
433
587
|
|
|
434
|
-
/*
|
|
435
|
-
.pc-btn{
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
.
|
|
439
|
-
.pc-btn
|
|
440
|
-
.pc-btn
|
|
441
|
-
.pc-btn.
|
|
442
|
-
.pc-btn.
|
|
443
|
-
.pc-btn.
|
|
444
|
-
.pc-btn.
|
|
445
|
-
.pc-
|
|
446
|
-
|
|
447
|
-
.pc-
|
|
448
|
-
.pc-
|
|
449
|
-
.pc-
|
|
450
|
-
.pc-
|
|
451
|
-
|
|
452
|
-
|
|
588
|
+
/* Button (int-btn). Default = secondary, md: 28px, 10px padding, 6px radius, 13px medium. */
|
|
589
|
+
.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);
|
|
590
|
+
display:inline-flex; align-items:center; justify-content:center; gap:6px; height:28px; padding:0 10px; border:0; border-radius:6px;
|
|
591
|
+
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;
|
|
592
|
+
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); }
|
|
593
|
+
@media (hover:hover){ .pc-btn:enabled:hover{ background:var(--bg-hover); color:var(--fg-hover); } }
|
|
594
|
+
.pc-btn:enabled:active{ background:var(--bg-active); transform:scale(.97); }
|
|
595
|
+
.pc-btn.sm{ height:24px; padding:0 8px; gap:4px; }
|
|
596
|
+
.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; }
|
|
597
|
+
.pc-btn.tertiary{ --bg:var(--pc-component); --bg-hover:var(--pc-active); box-shadow:none; }
|
|
598
|
+
.pc-btn.ghost{ --bg:transparent; --bg-hover:var(--pc-hover); --fg:var(--pc-text); --fg-hover:var(--pc-text-strong); box-shadow:none; }
|
|
599
|
+
.pc-btn:disabled{ background:var(--pc-component); color:var(--pc-disabled); box-shadow:none; cursor:not-allowed; }
|
|
600
|
+
/* icon-only transparent, sm: 24px */
|
|
601
|
+
.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); }
|
|
602
|
+
@media (hover:hover){ .pc-ibtn:hover{ background:var(--pc-hover); color:var(--pc-text-strong); } }
|
|
603
|
+
.pc-ibtn:active{ transform:scale(.97); }
|
|
604
|
+
@media (hover:hover){ .pc-trash:hover{ color:var(--pc-danger); background:var(--pc-danger-soft-hover); } }
|
|
605
|
+
|
|
606
|
+
/* Badge: 20px, 6px padding, 12px, 4px radius */
|
|
607
|
+
.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; }
|
|
608
|
+
|
|
609
|
+
/* Tabs: gray track, white indicator with a gray-6 border */
|
|
610
|
+
.pc-tabs{ display:inline-flex; align-self:flex-start; padding:0; border-radius:6px; background:var(--pc-component); font-weight:500; }
|
|
611
|
+
.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); }
|
|
612
|
+
.pc-tab span{ color:var(--pc-faint); font-variant-numeric:tabular-nums; }
|
|
613
|
+
@media (hover:hover){ .pc-tab:hover{ color:var(--pc-text-strong); } }
|
|
614
|
+
.pc-tab[aria-selected="true"]{ background:var(--pc-field); border-color:var(--pc-tab-border); color:var(--pc-text-strong); }
|
|
615
|
+
|
|
616
|
+
/* Kbd: 16px, 0.5px border, 4px radius, 11px medium uppercase */
|
|
617
|
+
.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
618
|
|
|
454
|
-
/*
|
|
455
|
-
.pc-
|
|
456
|
-
|
|
457
|
-
.pc-
|
|
458
|
-
.pc-
|
|
459
|
-
|
|
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); }
|
|
619
|
+
/* Checkbox: 14px, 1.5px border, 4px radius, brand fill when checked */
|
|
620
|
+
.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); }
|
|
621
|
+
@media (hover:hover){ .pc-root input[type=checkbox]:hover{ background-color:var(--pc-hover); } }
|
|
622
|
+
.pc-root input[type=checkbox]:active{ transform:scale(.97); }
|
|
623
|
+
.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; }
|
|
624
|
+
@media (hover:hover){ .pc-root input[type=checkbox]:checked:hover{ background-color:var(--pc-primary-hover); } }
|
|
464
625
|
|
|
465
|
-
/*
|
|
466
|
-
.pc-
|
|
467
|
-
.pc-
|
|
626
|
+
/* Textarea: white field with s-default, 8px radius; blue ring on focus */
|
|
627
|
+
.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; }
|
|
628
|
+
.pc-input::placeholder{ color:var(--pc-faint); }
|
|
629
|
+
@media (hover:hover){ .pc-input:hover{ background:var(--pc-field-hover); } }
|
|
630
|
+
.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); }
|
|
631
|
+
|
|
632
|
+
/* toolbar: standout surface, dark like the Tooltip */
|
|
633
|
+
.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; }
|
|
634
|
+
.pc-bar button{ --pc-tint:transparent; 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); }
|
|
635
|
+
@media (hover:hover){ .pc-bar button:hover{ --pc-tint:#ffffff1b; background:var(--pc-tint); color:#ffffffed; } }
|
|
636
|
+
.pc-bar button:active{ transform:scale(.97); }
|
|
637
|
+
.pc-bar button[aria-pressed="true"]{ --pc-tint:#ffffff22; background:var(--pc-tint); color:#ffffffed; box-shadow:0 0 0 .5px #ffffff12 inset; }
|
|
638
|
+
.pc-bar kbd{ border-color:#ffffff12; background:#ffffff12; color:#ffffffaf; }
|
|
639
|
+
/* open comments: a dot on the History icon. Its ring is the button's own color: the bar, plus the button's tint when hovered or pressed. */
|
|
640
|
+
.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-tint), 0 0 0 1.5px var(--pc-dark); transition:box-shadow .15s; }
|
|
641
|
+
/* Tooltip (DS): standout surface, 8px radius, 12px medium; shows after a short delay, hides at once */
|
|
642
|
+
.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); }
|
|
643
|
+
.pc-bar[data-tips="below"] .pc-tip{ bottom:auto; top:calc(100% + 10px); transform:translate(-50%, -4px) scale(.95); transform-origin:50% 0; }
|
|
644
|
+
.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; }
|
|
645
|
+
@media (hover:none){ .pc-tip{ display:none; } }
|
|
646
|
+
/* The same Tooltip for [data-tip] controls: <Tooltip> places it by the control, so a scrolling panel can't clip it */
|
|
647
|
+
/* max-content: near the right edge, shrink-to-fit would wrap it narrower than it needs */
|
|
648
|
+
.pc-tip.float{ position:fixed; z-index:calc(var(--pc-z) + 5); bottom:auto; width:max-content; max-width:min(320px, calc(100vw - 16px)); white-space:normal; overflow-wrap:anywhere; }
|
|
649
|
+
.pc-tip.float[data-side="below"]{ transform:translate(-50%, -4px) scale(.95); transform-origin:50% 0; }
|
|
650
|
+
.pc-tip.float[data-open]{ opacity:1; transform:translate(-50%, 0) scale(1); transition-duration:.2s; }
|
|
651
|
+
.pc-grip{ display:grid; place-items:center; width:20px; height:28px; opacity:.5; cursor:grab; touch-action:none; transition:opacity .15s; }
|
|
652
|
+
@media (hover:hover){ .pc-grip:hover{ opacity:1; } }
|
|
653
|
+
/* Tooltip */
|
|
654
|
+
.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); }
|
|
655
|
+
/* translate (the property) composes with the centering transform above */
|
|
656
|
+
@keyframes pc-hint-in{ from{ opacity:0; translate:0 4px; } }
|
|
657
|
+
|
|
658
|
+
/* pins */
|
|
659
|
+
.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); }
|
|
660
|
+
.pc-outline.flash{ animation:pc-flash 1.6s cubic-bezier(0,0,.2,1) forwards; }
|
|
468
661
|
@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:
|
|
470
|
-
.pc-pin.on{ background:var(--pc-
|
|
662
|
+
.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; }
|
|
663
|
+
.pc-pin.on{ background:#202020; z-index:calc(var(--pc-z) + 2); }
|
|
471
664
|
.pc-pin.queued, .pc-n.queued{ background:var(--pc-agent); }
|
|
472
|
-
.pc-pin.done{ background:var(--pc-
|
|
473
|
-
.pc-pin.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
.pc-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
.pc-
|
|
482
|
-
|
|
665
|
+
.pc-pin.done{ background:var(--pc-done); padding:0; width:24px; display:grid; place-items:center; }
|
|
666
|
+
.pc-pin .pc-i, .pc-n .pc-i{ width:12px; height:12px; }
|
|
667
|
+
.pc-pin.flash{ animation:pc-pin .5s cubic-bezier(.23,1,.32,1) 2; }
|
|
668
|
+
@keyframes pc-pin{ 50%{ transform:translate(-2px,-100%) scale(1.15) } }
|
|
669
|
+
/* a pin that was just left: grows out of its tail, where the draft popover was */
|
|
670
|
+
.pc-pin.born{ animation:pc-pin-in .2s cubic-bezier(.32,.72,0,1); }
|
|
671
|
+
@keyframes pc-pin-in{ from{ opacity:0; transform:translate(-2px,-100%) scale(.8) } }
|
|
672
|
+
|
|
673
|
+
/* Popover: 10px radius, s-extra-large, 0.5px dividers */
|
|
674
|
+
.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); }
|
|
675
|
+
/* transform-origin is set inline, at the pin or click point */
|
|
676
|
+
@keyframes pc-pop-in{ from{ opacity:0; transform:scale(.97) } }
|
|
677
|
+
.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); }
|
|
678
|
+
.pc-head b{ flex:1; font-weight:500; }
|
|
679
|
+
.pc-head .pc-tag{ margin:0 6px; }
|
|
680
|
+
/* header, reply field and actions stay put; only the messages scroll */
|
|
681
|
+
.pc-pane{ flex:none; display:flex; flex-direction:column; gap:10px; padding:12px; }
|
|
682
|
+
.pc-pane.msgs{ flex:0 1 auto; min-height:48px; overflow:auto; overscroll-behavior:contain; padding-bottom:0; }
|
|
683
|
+
.pc-pane.msgs + .pc-pane{ padding-top:10px; }
|
|
483
684
|
.pc-actions{ display:flex; justify-content:flex-end; gap:8px; }
|
|
484
|
-
.pc-actions.
|
|
485
|
-
.pc-msg{
|
|
486
|
-
.pc-meta{ display:block; font-size:12px; color:var(--pc-
|
|
487
|
-
.pc-lost{ font-size:12px; color:var(--pc-warn); background:var(--pc-warn-soft); border-radius:6px; padding:6px 8px; }
|
|
685
|
+
.pc-actions.foot{ flex:none; padding:6px 8px; border-top:.5px solid var(--pc-border); }
|
|
686
|
+
.pc-msg{ white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
687
|
+
.pc-meta{ display:block; font-size:12px; line-height:16px; color:var(--pc-faint); margin-bottom:2px; }
|
|
688
|
+
.pc-lost{ font-size:12px; line-height:16px; color:var(--pc-warn); background:var(--pc-warn-soft); border-radius:6px; padding:6px 8px; }
|
|
488
689
|
|
|
489
|
-
/*
|
|
490
|
-
.pc-history{ position:fixed; z-index:calc(var(--pc-z) + 3); top:0; right:0; bottom:0; width:
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
.pc-
|
|
494
|
-
.pc-
|
|
495
|
-
.pc-
|
|
496
|
-
.pc-
|
|
497
|
-
|
|
498
|
-
.pc-
|
|
499
|
-
.pc-
|
|
500
|
-
|
|
501
|
-
.pc-
|
|
502
|
-
.pc-
|
|
503
|
-
.pc-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
.pc-
|
|
507
|
-
|
|
508
|
-
.pc-row.
|
|
509
|
-
|
|
690
|
+
/* History: right Drawer */
|
|
691
|
+
.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;
|
|
692
|
+
transform:translateX(100%); visibility:hidden; transition:transform .18s cubic-bezier(.32,.72,0,1), visibility 0s .18s; }
|
|
693
|
+
/* a transition, not keyframes: toggling History mid-slide reverses it smoothly. Closing is faster than opening */
|
|
694
|
+
.pc-history[data-open]{ transform:none; visibility:visible; transition:transform .25s cubic-bezier(.32,.72,0,1); }
|
|
695
|
+
.pc-history header{ display:flex; align-items:center; gap:6px; padding:16px 16px 12px 20px; }
|
|
696
|
+
.pc-history header b{ flex:1; font-size:18px; line-height:24px; font-weight:500; }
|
|
697
|
+
.pc-history > .pc-tabs{ display:flex; align-self:stretch; margin:0 20px 12px; }
|
|
698
|
+
/* narrow drawer or 4-digit counts: tabs shrink instead of overflowing */
|
|
699
|
+
.pc-history > .pc-tabs .pc-tab{ flex:1 1 auto; min-width:0; justify-content:center; gap:4px; padding:0 6px; overflow:hidden; }
|
|
700
|
+
.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); }
|
|
701
|
+
/* only the box and its text toggle; margin:0 against the page's own label styles */
|
|
702
|
+
.pc-check{ flex:none; display:inline-flex; align-items:center; gap:8px; margin:0; cursor:pointer; color:var(--pc-text); font-weight:500; user-select:none; }
|
|
703
|
+
.pc-check:has(:checked){ color:var(--pc-text-strong); }
|
|
704
|
+
.pc-bulk{ display:flex; gap:2px; margin:0 -4px 0 auto; }
|
|
705
|
+
/* Command list: 6px-radius items */
|
|
706
|
+
/* the bottom padding keeps the last row clear of the toolbar when the drawer is full width */
|
|
707
|
+
.pc-list{ flex:1; overflow:auto; padding:8px 12px 72px; }
|
|
708
|
+
/* row: checkbox, pin and the first text line share one 20px line */
|
|
709
|
+
.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); }
|
|
710
|
+
/* fades before it leaves the filtered list */
|
|
711
|
+
.pc-row.leaving{ opacity:0; pointer-events:none; }
|
|
712
|
+
@media (hover:hover){ .pc-row:hover{ background:var(--pc-hover); } }
|
|
713
|
+
.pc-root .pc-row .pc-pick{ width:14px; height:14px; flex:none; margin:3px 0 0; }
|
|
714
|
+
.pc-item{ flex:1; min-width:0; display:flex; align-items:flex-start; gap:10px; padding:0; border:0; background:transparent; text-align:left; }
|
|
715
|
+
.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; }
|
|
716
|
+
/* row actions: the thread's Resolve, Mark for Agent and Delete; shown on hover or focus, always on touch */
|
|
717
|
+
/* overlaid at the row's right edge so hidden actions take no width; the fade matches the row's hover fill */
|
|
718
|
+
.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; }
|
|
719
|
+
.pc-row:hover .pc-row-actions, .pc-row:focus-within .pc-row-actions{ opacity:1; pointer-events:auto; }
|
|
720
|
+
/* on touch they are always there, so they take their own column instead of covering the text */
|
|
721
|
+
@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; } }
|
|
722
|
+
.pc-agent[aria-pressed="true"]{ color:var(--pc-agent-text); background:var(--pc-agent-soft); }
|
|
723
|
+
.pc-row.done .pc-n{ background:var(--pc-done); padding:0; width:20px; }
|
|
724
|
+
.pc-body{ flex:1; min-width:0; display:flex; flex-direction:column; gap:4px; }
|
|
725
|
+
.pc-text{ font-weight:450; overflow-wrap:anywhere; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
|
726
|
+
.pc-row.done .pc-text{ color:var(--pc-faint); }
|
|
727
|
+
.pc-body .pc-meta{ display:flex; align-items:center; gap:6px; min-width:0; margin:0; }
|
|
728
|
+
.pc-clip{ min-width:0; overflow:hidden; white-space:nowrap; }
|
|
729
|
+
.pc-clip.pc-cut{ -webkit-mask-image:linear-gradient(to right, #000 calc(100% - 24px), transparent); mask-image:linear-gradient(to right, #000 calc(100% - 24px), transparent); }
|
|
730
|
+
.pc-body .pc-tag{ margin:0; }
|
|
731
|
+
.pc-empty{ margin:8px; color:var(--pc-faint); }
|
|
732
|
+
|
|
733
|
+
@media (prefers-reduced-motion:reduce){ .pc-root *{ transition:none !important; animation:none !important; } }
|
|
510
734
|
`;
|
|
511
735
|
|
|
512
736
|
// src/index.jsx
|
|
@@ -528,20 +752,77 @@ function useStyles() {
|
|
|
528
752
|
document.head.appendChild(s);
|
|
529
753
|
}, []);
|
|
530
754
|
}
|
|
755
|
+
var clippers = (el) => {
|
|
756
|
+
const out = [];
|
|
757
|
+
for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
758
|
+
const s = getComputedStyle(n);
|
|
759
|
+
if (s.overflowX !== "visible" || s.overflowY !== "visible") out.push(n);
|
|
760
|
+
}
|
|
761
|
+
return out;
|
|
762
|
+
};
|
|
763
|
+
var inside = (x, y, els) => els.every((n) => {
|
|
764
|
+
const r = n.getBoundingClientRect();
|
|
765
|
+
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
766
|
+
});
|
|
767
|
+
var PIN_H = 24;
|
|
768
|
+
var pinWidth = (c) => c.status === "resolved" ? 24 : Math.max(24, 18 + 7 * String(c.n).length);
|
|
769
|
+
function place(list, pos) {
|
|
770
|
+
const placed = [];
|
|
771
|
+
list.filter((c) => pos[c.id]).sort((a, b) => a.n - b.n).forEach((c) => {
|
|
772
|
+
const p = pos[c.id], w = pinWidth(c);
|
|
773
|
+
p.x = Math.max(4, Math.min(p.x, window.innerWidth - w - 2));
|
|
774
|
+
p.y = Math.max(PIN_H + 4, Math.min(p.y, window.innerHeight - 4));
|
|
775
|
+
const startX = p.x;
|
|
776
|
+
for (let moved = true; moved; ) {
|
|
777
|
+
moved = false;
|
|
778
|
+
for (const q of placed) {
|
|
779
|
+
if (Math.abs(p.y - q.y) < PIN_H && p.x < q.x + q.w + 2 && p.x + w + 2 > q.x) {
|
|
780
|
+
p.x = q.x + q.w + 2;
|
|
781
|
+
moved = true;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (p.x + w > window.innerWidth - 2) {
|
|
785
|
+
p.x = startX;
|
|
786
|
+
p.y += PIN_H + 2;
|
|
787
|
+
moved = true;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
placed.push({ x: p.x, y: p.y, w });
|
|
791
|
+
});
|
|
792
|
+
}
|
|
531
793
|
function useFrame(list, openId, flashId) {
|
|
532
794
|
const [frame, setFrame] = useState2({ screen: null, pos: {} });
|
|
533
795
|
useEffect2(() => {
|
|
534
|
-
let raf, last = "";
|
|
796
|
+
let raf, last = "", lastScreen = null, dirty = true, found = {};
|
|
797
|
+
const mo = new MutationObserver((records) => {
|
|
798
|
+
if (records.some((r) => !ours(r.target.nodeType === 1 ? r.target : r.target.parentElement))) dirty = true;
|
|
799
|
+
});
|
|
800
|
+
mo.observe(document.body, { childList: true, characterData: true, subtree: true });
|
|
801
|
+
const timer = setInterval(() => {
|
|
802
|
+
dirty = true;
|
|
803
|
+
}, 1e3);
|
|
535
804
|
const tick = () => {
|
|
536
805
|
const screen = getConfig().getScreen().key;
|
|
806
|
+
const shown = list.filter((c) => c.screen === screen && (c.status === "open" || c.id === openId));
|
|
807
|
+
if (dirty || screen !== lastScreen) {
|
|
808
|
+
found = {};
|
|
809
|
+
shown.forEach((c) => {
|
|
810
|
+
const el = findAnchor(c.anchor);
|
|
811
|
+
if (el) found[c.id] = { el, clip: clippers(el) };
|
|
812
|
+
});
|
|
813
|
+
dirty = false;
|
|
814
|
+
lastScreen = screen;
|
|
815
|
+
}
|
|
537
816
|
const pos = {};
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const r =
|
|
543
|
-
|
|
817
|
+
shown.forEach((c) => {
|
|
818
|
+
const f = found[c.id];
|
|
819
|
+
if (!f || !f.el.isConnected || !f.el.getClientRects().length) return;
|
|
820
|
+
const r = rectOf(f.el);
|
|
821
|
+
const x = r.left + c.anchor.offset.x * r.width, y = r.top + c.anchor.offset.y * r.height;
|
|
822
|
+
if (c.id !== openId && !inside(x, y, f.clip)) return;
|
|
823
|
+
pos[c.id] = { x, y, rect: c.id === flashId ? r : null };
|
|
544
824
|
});
|
|
825
|
+
place(shown, pos);
|
|
545
826
|
const key = JSON.stringify([screen, pos]);
|
|
546
827
|
if (key !== last) {
|
|
547
828
|
last = key;
|
|
@@ -550,7 +831,11 @@ function useFrame(list, openId, flashId) {
|
|
|
550
831
|
raf = requestAnimationFrame(tick);
|
|
551
832
|
};
|
|
552
833
|
tick();
|
|
553
|
-
return () =>
|
|
834
|
+
return () => {
|
|
835
|
+
cancelAnimationFrame(raf);
|
|
836
|
+
clearInterval(timer);
|
|
837
|
+
mo.disconnect();
|
|
838
|
+
};
|
|
554
839
|
}, [list, openId, flashId]);
|
|
555
840
|
return frame;
|
|
556
841
|
}
|
|
@@ -566,7 +851,7 @@ function ProtoComments(props) {
|
|
|
566
851
|
setRoot(el);
|
|
567
852
|
return () => el.remove();
|
|
568
853
|
}, []);
|
|
569
|
-
const { list, mode, draft, openId,
|
|
854
|
+
const { list, mode, draft, openId, flashId } = useC((s) => s);
|
|
570
855
|
const [hover, setHover] = useState2(null);
|
|
571
856
|
useEffect2(() => {
|
|
572
857
|
const load = () => {
|
|
@@ -597,6 +882,7 @@ function ProtoComments(props) {
|
|
|
597
882
|
setC({ mode: false, draft: null });
|
|
598
883
|
}
|
|
599
884
|
if (e.code === "KeyC" && !c.mode && !typing(e.target)) setC({ mode: true, openId: null });
|
|
885
|
+
if (e.code === "KeyH" && !typing(e.target)) setC({ historyOpen: !c.historyOpen });
|
|
600
886
|
};
|
|
601
887
|
window.addEventListener("keydown", onKey, true);
|
|
602
888
|
return () => window.removeEventListener("keydown", onKey, true);
|
|
@@ -623,7 +909,12 @@ function ProtoComments(props) {
|
|
|
623
909
|
document.documentElement.classList.remove("pc-mode");
|
|
624
910
|
};
|
|
625
911
|
}, [mode, cfg2.headingSelector]);
|
|
626
|
-
const { pos } = useFrame(list, openId, flashId);
|
|
912
|
+
const { screen, pos } = useFrame(list, openId, flashId);
|
|
913
|
+
const lastScreen = useRef2(screen);
|
|
914
|
+
useEffect2(() => {
|
|
915
|
+
if (lastScreen.current !== null && screen !== lastScreen.current) setC({ openId: null, draft: null });
|
|
916
|
+
lastScreen.current = screen;
|
|
917
|
+
}, [screen]);
|
|
627
918
|
const here = list.filter((c) => pos[c.id]);
|
|
628
919
|
const open = list.find((c) => c.id === openId);
|
|
629
920
|
if (!root) return null;
|
|
@@ -633,9 +924,10 @@ function ProtoComments(props) {
|
|
|
633
924
|
here.map((c) => /* @__PURE__ */ jsx3(Pin, { c, x: pos[c.id].x, y: pos[c.id].y }, c.id)),
|
|
634
925
|
here.map((c) => pos[c.id].rect && /* @__PURE__ */ jsx3("div", { className: "pc-outline flash", style: pos[c.id].rect }, `f${c.id}`)),
|
|
635
926
|
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
|
-
|
|
927
|
+
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)),
|
|
928
|
+
/* @__PURE__ */ jsx3(History, {}),
|
|
638
929
|
/* @__PURE__ */ jsx3(Toolbar, {}),
|
|
930
|
+
/* @__PURE__ */ jsx3(Tooltip, {}),
|
|
639
931
|
mode && /* @__PURE__ */ jsx3("div", { className: "pc-hint", children: "Click an element to comment \xB7 V to stop" })
|
|
640
932
|
] }),
|
|
641
933
|
root
|
package/package.json
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proto-comments",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": [
|
|
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": {
|
|
17
|
-
|
|
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": {
|
|
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 =
|
|
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
|
|
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 };
|