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 +7 -4
- package/bin/proto-comments.js +7 -4
- package/dist/index.js +495 -210
- package/package.json +25 -5
- package/server/storage.js +14 -3
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
|
-
| **
|
|
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, 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
|
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,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: "
|
|
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 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
|
-
|
|
226
|
-
|
|
227
|
-
/* @__PURE__ */ jsx2(
|
|
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
|
-
|
|
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__ */
|
|
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: () =>
|
|
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__ */
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
c.
|
|
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.
|
|
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
|
-
|
|
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" }) })
|
|
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 = [["
|
|
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.
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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-
|
|
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 &&
|
|
331
|
-
|
|
332
|
-
"Mark for Agent (",
|
|
333
|
-
|
|
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__ */
|
|
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
|
-
|
|
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
|
-
] },
|
|
355
|
-
|
|
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",
|
|
395
|
-
/* @__PURE__ */ jsxs2("button", { "aria-pressed": mode, onClick: () => setC({ mode: !getC().mode, draft: null, openId: null }),
|
|
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__ */
|
|
398
|
-
|
|
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 }),
|
|
401
|
-
/* @__PURE__ */ jsx2(Icon, { name: "
|
|
402
|
-
/* @__PURE__ */ jsx2("
|
|
403
|
-
|
|
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
|
-
|
|
412
|
-
--pc-
|
|
413
|
-
--pc-
|
|
414
|
-
--pc-
|
|
415
|
-
--pc-
|
|
416
|
-
--pc-
|
|
417
|
-
--pc-
|
|
418
|
-
--pc-
|
|
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/
|
|
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
|
-
|
|
425
|
-
.pc-root
|
|
426
|
-
.pc-root :
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
/*
|
|
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); }
|
|
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
|
-
/*
|
|
466
|
-
.pc-
|
|
467
|
-
.pc-
|
|
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:
|
|
470
|
-
.pc-pin.on{ background:var(--pc-
|
|
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-
|
|
473
|
-
.pc-pin.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
.pc-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
.pc-
|
|
482
|
-
|
|
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.
|
|
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; }
|
|
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
|
-
/*
|
|
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
|
-
.pc-
|
|
501
|
-
.pc-
|
|
502
|
-
.pc-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
.pc-
|
|
506
|
-
|
|
507
|
-
.pc-
|
|
508
|
-
|
|
509
|
-
.pc-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const r =
|
|
543
|
-
|
|
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 () =>
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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": [
|
|
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 };
|