@augurworks/augur 0.15.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/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,1738 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Prototype review overlay.
|
|
3
|
+
*
|
|
4
|
+
* Injected into every prototype's HTML by build.js (as
|
|
5
|
+
* `<script src="/__review/comments.js" defer>`), DORMANT until activated.
|
|
6
|
+
*
|
|
7
|
+
* Shift+C → toggle review mode. Turning it ON drops you straight into
|
|
8
|
+
* "placing" mode: click anywhere to attach a comment. Nothing is laid over
|
|
9
|
+
* the page while you do — the cursor changes, a mouse press is swallowed so
|
|
10
|
+
* the content underneath can't be clicked or dragged, and scroll/pinch/zoom
|
|
11
|
+
* stay entirely native (see "placing mode" below).
|
|
12
|
+
* Esc → placing → browse (interact with the prototype, drag pins);
|
|
13
|
+
* then close an open thread; then turn review off.
|
|
14
|
+
*
|
|
15
|
+
* Pins are anchored to the element you click (CSS selector + offset) and to the
|
|
16
|
+
* screen/URL they were made on. A collapsible right sidebar lists every comment;
|
|
17
|
+
* clicking one opens it in place, navigating to its screen first if needed.
|
|
18
|
+
*
|
|
19
|
+
* Lifecycle: a comment lives as long as its anchor element exists. If you're on
|
|
20
|
+
* the screen it was made on and that element is gone (removed by dev work), the
|
|
21
|
+
* comment auto-deletes. On a different screen it's just hidden from the canvas
|
|
22
|
+
* but stays listed (and navigable) in the sidebar.
|
|
23
|
+
*
|
|
24
|
+
* Annotations: open a thread and hit the cat toggle to promote it to an
|
|
25
|
+
* ANNOTATION — a delivery note for devs. Annotations (1) render even with review
|
|
26
|
+
* mode OFF, as the cat avatar instead of a numbered pin (hover/tap shows the
|
|
27
|
+
* note); (2) are exempt from the orphan auto-delete; (3) are skipped by the
|
|
28
|
+
* resolve-comments tooling (scripts/review.mjs). It's the inverse of a comment:
|
|
29
|
+
* comments are transient feedback, annotations are persistent always-on spec.
|
|
30
|
+
* The avatar ships as a sibling file (cat.png), copied to /__review/ by build.js.
|
|
31
|
+
*
|
|
32
|
+
* Shared via the worker's KV API (/__review/api); falls back to localStorage if
|
|
33
|
+
* the API is unreachable. Completely inert inside the index-page preview iframes.
|
|
34
|
+
*/
|
|
35
|
+
(function () {
|
|
36
|
+
"use strict";
|
|
37
|
+
|
|
38
|
+
if (window.top !== window.self) return; // never run in preview iframes
|
|
39
|
+
if (window.__gvReview) return;
|
|
40
|
+
window.__gvReview = true;
|
|
41
|
+
|
|
42
|
+
var API = "/__review/api";
|
|
43
|
+
var PATH = location.pathname;
|
|
44
|
+
var LS_THREADS = "gv-review:" + PATH;
|
|
45
|
+
var LS_NAME = "gv-review-name";
|
|
46
|
+
var LS_ACTIVE = "gv-review-active";
|
|
47
|
+
var LS_SB = "gv-review-sb-collapsed";
|
|
48
|
+
var SS_PENDING = "gv-review-pending-open";
|
|
49
|
+
|
|
50
|
+
// Annotations = comments promoted to always-on dev-delivery notes. They render
|
|
51
|
+
// even when review mode is off, are skipped by the resolve-comments tooling, and
|
|
52
|
+
// show as this avatar instead of a numbered pin. Served as a real same-origin
|
|
53
|
+
// file (next to this script) rather than a data: URI — some privacy blockers
|
|
54
|
+
// refuse to paint inline images, and a plain <img src> is bulletproof.
|
|
55
|
+
var CAT = "/__review/cat.png";
|
|
56
|
+
|
|
57
|
+
var state = { threads: [], active: false, mode: "add", openId: null, drill: 0 };
|
|
58
|
+
var deleted = {}; // ids we've already issued a delete for
|
|
59
|
+
var settled = false; // becomes true after load grace period
|
|
60
|
+
|
|
61
|
+
/* ---------- storage ---------- */
|
|
62
|
+
|
|
63
|
+
function loadLocal() {
|
|
64
|
+
try { return JSON.parse(localStorage.getItem(LS_THREADS) || "[]"); }
|
|
65
|
+
catch (e) { return []; }
|
|
66
|
+
}
|
|
67
|
+
function saveLocal() {
|
|
68
|
+
try { localStorage.setItem(LS_THREADS, JSON.stringify(state.threads)); } catch (e) {}
|
|
69
|
+
}
|
|
70
|
+
async function apiCall(method, body) {
|
|
71
|
+
var res = await fetch(API + "?path=" + encodeURIComponent(PATH), {
|
|
72
|
+
method: method,
|
|
73
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
74
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
75
|
+
credentials: "same-origin",
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) throw new Error("api " + res.status);
|
|
78
|
+
return res.json();
|
|
79
|
+
}
|
|
80
|
+
async function refresh() {
|
|
81
|
+
try {
|
|
82
|
+
var data = await apiCall("GET");
|
|
83
|
+
state.threads = (data && data.threads) || [];
|
|
84
|
+
saveLocal();
|
|
85
|
+
} catch (e) {
|
|
86
|
+
state.threads = loadLocal();
|
|
87
|
+
}
|
|
88
|
+
render();
|
|
89
|
+
tryOpenPending();
|
|
90
|
+
loadPeople();
|
|
91
|
+
}
|
|
92
|
+
async function mutate(op) {
|
|
93
|
+
try {
|
|
94
|
+
var data = await apiCall("POST", op);
|
|
95
|
+
state.threads = (data && data.threads) || [];
|
|
96
|
+
saveLocal();
|
|
97
|
+
} catch (e) {
|
|
98
|
+
applyLocal(op); saveLocal();
|
|
99
|
+
}
|
|
100
|
+
render();
|
|
101
|
+
loadPeople();
|
|
102
|
+
}
|
|
103
|
+
function applyLocal(op) {
|
|
104
|
+
var t;
|
|
105
|
+
if (op.op === "add") state.threads.push(op.thread);
|
|
106
|
+
else if (op.op === "reply") { t = find(op.id); if (t) t.messages.push(op.message); }
|
|
107
|
+
else if (op.op === "resolve") { t = find(op.id); if (t) t.resolved = op.resolved; }
|
|
108
|
+
else if (op.op === "move") {
|
|
109
|
+
t = find(op.id);
|
|
110
|
+
if (t) { t.sel = op.sel; t.fx = op.fx; t.fy = op.fy; t.px = op.px; t.py = op.py; t.view = op.view; }
|
|
111
|
+
} else if (op.op === "annotate") { t = find(op.id); if (t) t.annotation = !!op.annotation; }
|
|
112
|
+
else if (op.op === "delmsg") {
|
|
113
|
+
t = find(op.id);
|
|
114
|
+
if (t) {
|
|
115
|
+
if (op.index === 0) state.threads = state.threads.filter(function (x) { return x.id !== op.id; });
|
|
116
|
+
else if (t.messages) t.messages = t.messages.filter(function (_, i) { return i !== op.index; });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (op.op === "delete") state.threads = state.threads.filter(function (x) { return x.id !== op.id; });
|
|
120
|
+
}
|
|
121
|
+
function find(id) { return state.threads.filter(function (t) { return t.id === id; })[0]; }
|
|
122
|
+
function isAnno(t) { return !!t.annotation; }
|
|
123
|
+
// Deterministic per-pin tilt (-12°..+12°) hashed from the id, so a stamp keeps
|
|
124
|
+
// the same angle across re-renders instead of jittering.
|
|
125
|
+
function annoRot(id) {
|
|
126
|
+
var h = 0; for (var i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0;
|
|
127
|
+
return (Math.abs(h) % 25) - 12;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---------- views & anchoring ---------- */
|
|
131
|
+
|
|
132
|
+
function curView() { return location.pathname + location.search + location.hash; }
|
|
133
|
+
function normView(v) {
|
|
134
|
+
if (v == null) return null;
|
|
135
|
+
try { var u = new URL(v, location.href); return u.pathname + u.search + u.hash; }
|
|
136
|
+
catch (e) { return v; }
|
|
137
|
+
}
|
|
138
|
+
function onThisView(t) { return t.view == null || normView(t.view) === curView(); }
|
|
139
|
+
function safeQuery(sel) { try { return sel ? document.querySelector(sel) : null; } catch (e) { return null; } }
|
|
140
|
+
function anchorOf(t) { return safeQuery(t.sel); }
|
|
141
|
+
function resolvesHere(t) { return !!anchorOf(t); }
|
|
142
|
+
|
|
143
|
+
// Screen contract: SPA prototypes change "screens" without changing the URL, so
|
|
144
|
+
// the prototype publishes its current screen on <body data-gv-screen="…"> (see
|
|
145
|
+
// CLAUDE.md). A comment captures that string at creation; it then only belongs
|
|
146
|
+
// on the matching screen. Empty string => prototype isn't screen-aware (normal
|
|
147
|
+
// multi-page), so scoping falls back to the URL exactly as before.
|
|
148
|
+
function curScreen() {
|
|
149
|
+
try { return (document.body && document.body.getAttribute("data-gv-screen")) || ""; }
|
|
150
|
+
catch (e) { return ""; }
|
|
151
|
+
}
|
|
152
|
+
function onThisScreen(t) {
|
|
153
|
+
if (!t.screen) return true; // comment isn't screen-scoped → URL rules apply
|
|
154
|
+
if (!onThisView(t)) return true; // on another URL → defer the check until we arrive
|
|
155
|
+
return t.screen === curScreen();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Belongs on the current canvas/sidebar: its anchor is here, or it lives on
|
|
159
|
+
// another URL (kept for navigation). A screen-scoped comment on the wrong screen
|
|
160
|
+
// is fully hidden — not a pin, not in the list.
|
|
161
|
+
function isListed(t) { return onThisScreen(t) && (resolvesHere(t) || !onThisView(t)); }
|
|
162
|
+
|
|
163
|
+
function cssPath(el) {
|
|
164
|
+
if (!el || el.nodeType !== 1) return "body";
|
|
165
|
+
if (el === document.body || el === document.documentElement) return "body";
|
|
166
|
+
var parts = [];
|
|
167
|
+
while (el && el.nodeType === 1 && el !== document.body && parts.length < 12) {
|
|
168
|
+
var sel = el.nodeName.toLowerCase(), p = el.parentNode;
|
|
169
|
+
if (p && p.children) {
|
|
170
|
+
var same = [];
|
|
171
|
+
for (var i = 0; i < p.children.length; i++)
|
|
172
|
+
if (p.children[i].nodeName === el.nodeName) same.push(p.children[i]);
|
|
173
|
+
if (same.length > 1) sel += ":nth-of-type(" + (same.indexOf(el) + 1) + ")";
|
|
174
|
+
}
|
|
175
|
+
parts.unshift(sel);
|
|
176
|
+
el = el.parentElement;
|
|
177
|
+
}
|
|
178
|
+
return parts.join(">");
|
|
179
|
+
}
|
|
180
|
+
function pinXY(t) {
|
|
181
|
+
// Canvas threads are anchored in world (board) coordinates: when this page mounts the
|
|
182
|
+
// infinite-canvas engine, map the stored world point through the live pan/zoom transform
|
|
183
|
+
// so the pin stays glued to the board as it moves. (Normal pages have no GVCanvas → skip.)
|
|
184
|
+
if (t.cwx != null && window.GVCanvas) return window.GVCanvas.worldToScreen(t.cwx, t.cwy);
|
|
185
|
+
var el = anchorOf(t);
|
|
186
|
+
if (!el) return null;
|
|
187
|
+
// Body/page-level anchors use absolute page coords (the body box is the
|
|
188
|
+
// whole page, so fractions are meaningless); element anchors use the rect.
|
|
189
|
+
if (el === document.body || el === document.documentElement) {
|
|
190
|
+
return { x: t.px - window.scrollX, y: t.py - window.scrollY };
|
|
191
|
+
}
|
|
192
|
+
var r = el.getBoundingClientRect();
|
|
193
|
+
return { x: r.left + t.fx * r.width, y: r.top + t.fy * r.height };
|
|
194
|
+
}
|
|
195
|
+
function anchorAt(x, y) {
|
|
196
|
+
// Hide the overlay while hit-testing: pins and the sidebar sit over the page, and a
|
|
197
|
+
// comment must anchor to what you clicked, never to the chrome in front of it.
|
|
198
|
+
var prev = host.style.display;
|
|
199
|
+
host.style.display = "none";
|
|
200
|
+
var el = document.elementFromPoint(x, y);
|
|
201
|
+
host.style.display = prev;
|
|
202
|
+
if (!el || host.contains(el)) el = document.body;
|
|
203
|
+
var r = el.getBoundingClientRect();
|
|
204
|
+
var loc = {
|
|
205
|
+
sel: cssPath(el),
|
|
206
|
+
fx: r.width ? (x - r.left) / r.width : 0.5,
|
|
207
|
+
fy: r.height ? (y - r.top) / r.height : 0.5,
|
|
208
|
+
px: x + window.scrollX, py: y + window.scrollY,
|
|
209
|
+
view: curView(),
|
|
210
|
+
screen: curScreen(),
|
|
211
|
+
};
|
|
212
|
+
// On an infinite canvas, also record the point in world (board) coordinates so the pin
|
|
213
|
+
// tracks pan/zoom instead of sticking to the screen. pinXY() prefers these when present;
|
|
214
|
+
// the engine dispatches a window "scroll" on every transform, which re-runs reposition().
|
|
215
|
+
if (window.GVCanvas) { var w = window.GVCanvas.screenToWorld(x, y); loc.cwx = w.x; loc.cwy = w.y; }
|
|
216
|
+
return loc;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function uid() { return Date.now().toString(36) + "-" + Math.floor(Math.random() * 1e6).toString(36); }
|
|
220
|
+
function nowIso() { return new Date().toISOString(); }
|
|
221
|
+
function getName() { try { return localStorage.getItem(LS_NAME) || ""; } catch (e) { return ""; } }
|
|
222
|
+
function setName(n) { try { localStorage.setItem(LS_NAME, n); } catch (e) {} }
|
|
223
|
+
|
|
224
|
+
// Who we are, if signed in. Used for the author name (as before), for the reply
|
|
225
|
+
// bar's own avatar, and to attribute a comment written while the API is unreachable
|
|
226
|
+
// — the localStorage path has no server to stamp `by` for it. The server rebuilds
|
|
227
|
+
// every message from the session regardless, so this can't forge anything.
|
|
228
|
+
var ME = null;
|
|
229
|
+
var PEOPLE = {}; // id -> person
|
|
230
|
+
var BYNAME = {}; // name -> person (back-compat for messages with no `by`)
|
|
231
|
+
|
|
232
|
+
function loadMe() {
|
|
233
|
+
return fetch("/__me", { headers: { Accept: "application/json" } })
|
|
234
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
235
|
+
.then(function (d) {
|
|
236
|
+
if (!d || !d.user) return;
|
|
237
|
+
ME = { id: d.user.id, name: d.user.name, initials: d.user.initials,
|
|
238
|
+
color: d.user.color, avatar: d.user.avatar };
|
|
239
|
+
if (ME.name) setName(ME.name);
|
|
240
|
+
if (ME.id) { PEOPLE[ME.id] = ME; BYNAME[ME.name] = ME; }
|
|
241
|
+
})
|
|
242
|
+
.catch(function () {});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// One request for every author on this page we don't already hold. Ids come from
|
|
246
|
+
// the threads themselves, so we never ask for — and can never receive — the roster.
|
|
247
|
+
var peoplePending = false;
|
|
248
|
+
function loadPeople() {
|
|
249
|
+
var ids = {}, names = {};
|
|
250
|
+
state.threads.forEach(function (t) {
|
|
251
|
+
(t.messages || []).forEach(function (m) {
|
|
252
|
+
if (m.by && !PEOPLE[m.by]) ids[m.by] = 1;
|
|
253
|
+
// Pre-`by` comments: a verified name is guaranteed by the server to belong to
|
|
254
|
+
// a real account, so it is safe to resolve. An unverified name is just a
|
|
255
|
+
// string someone typed — never look it up.
|
|
256
|
+
else if (!m.by && m.verified && m.author && !BYNAME[m.author]) names[m.author] = 1;
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
var idList = Object.keys(ids), nameList = Object.keys(names);
|
|
260
|
+
if (peoplePending || (!idList.length && !nameList.length)) return;
|
|
261
|
+
var q = [];
|
|
262
|
+
// /__people caps ids.length + names.length COMBINED at PEOPLE_LOOKUP_MAX (50, see
|
|
263
|
+
// peopleApi in _worker.js) — it is not 50 of each. Ids win the budget first since an
|
|
264
|
+
// id is an exact, current identity, while a name lookup is only a back-compat path
|
|
265
|
+
// for pre-`by` comments; whatever's left after ids goes to names. Anything that
|
|
266
|
+
// doesn't fit is simply dropped, not retried — those authors still get initials via
|
|
267
|
+
// fromName(), so this degrades gracefully on its own.
|
|
268
|
+
var idPart = idList.slice(0, 50);
|
|
269
|
+
var namePart = nameList.slice(0, Math.max(0, 50 - idPart.length));
|
|
270
|
+
if (idPart.length) q.push("ids=" + encodeURIComponent(idPart.join(",")));
|
|
271
|
+
if (namePart.length) q.push("names=" + encodeURIComponent(namePart.join(",")));
|
|
272
|
+
peoplePending = true;
|
|
273
|
+
fetch("/__people?" + q.join("&"), { headers: { Accept: "application/json" } })
|
|
274
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
275
|
+
.then(function (d) {
|
|
276
|
+
peoplePending = false;
|
|
277
|
+
if (!d || !d.people) return;
|
|
278
|
+
d.people.forEach(function (p) { PEOPLE[p.id] = p; BYNAME[p.name] = p; });
|
|
279
|
+
render();
|
|
280
|
+
})
|
|
281
|
+
.catch(function () { peoplePending = false; });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// If signed in to Augur, adopt the profile name as the comment author (so the name
|
|
285
|
+
// prompt is skipped and comments are attributed to the real person). On public
|
|
286
|
+
// prototypes there's no login → /__me returns { user: null } and this is a no-op.
|
|
287
|
+
loadMe();
|
|
288
|
+
|
|
289
|
+
/* ---------- shadow UI ---------- */
|
|
290
|
+
|
|
291
|
+
var host = document.createElement("div");
|
|
292
|
+
host.id = "gv-review-host";
|
|
293
|
+
host.style.cssText = "all:initial;position:fixed;inset:0;z-index:2147483646;pointer-events:none;";
|
|
294
|
+
var root = host.attachShadow({ mode: "open" });
|
|
295
|
+
document.documentElement.appendChild(host);
|
|
296
|
+
|
|
297
|
+
root.innerHTML =
|
|
298
|
+
'<style>' +
|
|
299
|
+
':host,*{box-sizing:border-box;}' +
|
|
300
|
+
'.layer{position:fixed;inset:0;pointer-events:none;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;}' +
|
|
301
|
+
'.hidden{display:none!important;}' +
|
|
302
|
+
'.pin{position:fixed;pointer-events:auto;transform:translate(-50%,-100%);cursor:grab;display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50% 50% 50% 2px;background:#2563eb;color:#fff;font-weight:600;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid #fff;touch-action:none;}' +
|
|
303
|
+
'.pin.resolved{background:#16a34a;}' +
|
|
304
|
+
/* a pin that knows who wrote it: the face fills the teardrop, keeping the notch */
|
|
305
|
+
'.pin.who{width:28px;height:28px;background:#fff;padding:0;overflow:hidden;}' +
|
|
306
|
+
/* width/height come from avatarEl's inline style; only the teardrop notch is ours */
|
|
307
|
+
'.pin.who .av{border-radius:50% 50% 50% 2px;}' +
|
|
308
|
+
/* resolved keeps its green as a ring, not a fill — a filled disc would hide the face */
|
|
309
|
+
'.pin.who.resolved{background:#fff;border-color:#16a34a;}' +
|
|
310
|
+
/* hidden while the hover preview is up — its avatar column takes the pin's place */
|
|
311
|
+
'.pin.under{visibility:hidden;}' +
|
|
312
|
+
'.pin.active{outline:3px solid rgba(37,99,235,0.4);}' +
|
|
313
|
+
'.pin.dragging{cursor:grabbing;opacity:0.85;}' +
|
|
314
|
+
/* annotation pin: cat avatar, centred on its anchor, always-on. --rot is a
|
|
315
|
+
per-pin random tilt (set in JS) so they read like hand-pressed stamps. */
|
|
316
|
+
'.pin.anno{width:38px;height:38px;border-radius:50%;background:#fff;border:2px solid #fff;padding:0;overflow:hidden;transform:translate(-50%,-50%) rotate(var(--rot,0deg));box-shadow:0 2px 9px rgba(0,0,0,0.34);cursor:pointer;transition:transform .18s cubic-bezier(.34,1.56,.64,1),box-shadow .18s ease;}' +
|
|
317
|
+
'.pin.anno img{width:100%;height:100%;object-fit:cover;display:block;border-radius:50%;pointer-events:none;}' +
|
|
318
|
+
'.pin.anno:hover{transform:translate(-50%,-50%) rotate(0deg) scale(1.16);box-shadow:0 7px 20px rgba(0,0,0,0.42);z-index:2;}' +
|
|
319
|
+
'.pin.anno.active{outline:3px solid rgba(61,116,244,0.45);}' +
|
|
320
|
+
'.av{flex:0 0 auto;border-radius:50%;object-fit:cover;display:block;}' +
|
|
321
|
+
'.av.ini{display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;line-height:1;letter-spacing:.02em;}' +
|
|
322
|
+
/* note bubble (delivery mode), styled like a cursor-chat bubble: blue pill + tail */
|
|
323
|
+
'.atip{position:fixed;pointer-events:none;max-width:340px;background:#5672da;color:#fff;padding:11px 16px;border-radius:18px;font:400 14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;box-shadow:0 4px 14px rgba(0,0,0,0.22),0 1px 3px rgba(0,0,0,0.12);transform:translate(-50%,var(--ty,-100%)) scale(.9);transform-origin:50% 110%;opacity:0;transition:opacity .14s ease,transform .2s cubic-bezier(.34,1.56,.64,1);white-space:pre-wrap;word-wrap:break-word;}' +
|
|
324
|
+
'.atip::after{content:"";position:absolute;left:var(--tail-x,50%);bottom:-4px;width:9px;height:9px;background:#5672da;transform:translateX(-50%) rotate(45deg);border-radius:0 0 3px 0;}' +
|
|
325
|
+
'.atip.below{--ty:0;transform-origin:50% -10%;}' +
|
|
326
|
+
'.atip.below::after{bottom:auto;top:-4px;border-radius:3px 0 0 0;}' +
|
|
327
|
+
'.atip.show{opacity:1;transform:translate(-50%,var(--ty,-100%)) scale(1);}' +
|
|
328
|
+
/* sidebar */
|
|
329
|
+
'.sb{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:85vw;pointer-events:auto;background:#fff;border-left:1px solid #e5e7eb;box-shadow:-6px 0 24px rgba(0,0,0,0.10);display:flex;flex-direction:column;transition:transform .18s ease;}' +
|
|
330
|
+
'.sb.collapsed{transform:translateX(100%);}' +
|
|
331
|
+
'.sb header{display:flex;align-items:center;gap:8px;padding:14px 14px 10px;border-bottom:1px solid #f0f0f0;}' +
|
|
332
|
+
'.sb header strong{font-size:14px;}' +
|
|
333
|
+
'.sb header .cnt{color:#6b7280;font-size:12px;flex:1;}' +
|
|
334
|
+
'.sb header button{font:inherit;cursor:pointer;border:1px solid #e5e7eb;background:#fff;color:#1a1a1a;border-radius:8px;width:30px;height:30px;line-height:1;font-size:16px;}' +
|
|
335
|
+
'.sb header button:hover{background:#f3f4f6;}' +
|
|
336
|
+
'.sb .hint{padding:8px 14px;color:#6b7280;font-size:12px;border-bottom:1px solid #f0f0f0;}' +
|
|
337
|
+
'.sb .list{list-style:none;margin:0;padding:6px;overflow:auto;flex:1;}' +
|
|
338
|
+
'.sb .it{display:flex;gap:9px;align-items:flex-start;padding:9px 8px;border-radius:9px;cursor:pointer;}' +
|
|
339
|
+
'.sb .it:hover{background:#f3f4f6;}' +
|
|
340
|
+
'.sb .it.active{background:#eef2ff;}' +
|
|
341
|
+
'.sb .it .num{flex:0 0 auto;width:20px;height:20px;border-radius:50%;background:#2563eb;color:#fff;font-size:11px;font-weight:600;display:flex;align-items:center;justify-content:center;margin-top:1px;}' +
|
|
342
|
+
'.sb .it.resolved .num{background:#16a34a;}' +
|
|
343
|
+
'.sb .it.anno .num{background:#fff;padding:0;overflow:hidden;}' +
|
|
344
|
+
'.sb .it.anno .num img{width:100%;height:100%;object-fit:cover;display:block;border-radius:50%;}' +
|
|
345
|
+
'.sb .it .num.face{background:0;padding:0;overflow:hidden;}' +
|
|
346
|
+
'.sb .it .txt{flex:1;min-width:0;}' +
|
|
347
|
+
'.sb .it .body{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}' +
|
|
348
|
+
'.sb .it .meta{display:block;color:#9ca3af;font-size:11px;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}' +
|
|
349
|
+
'.sb .it .away{color:#2563eb;}' +
|
|
350
|
+
'.sb .empty{padding:18px 14px;color:#9ca3af;font-size:13px;}' +
|
|
351
|
+
/* collapsed handle */
|
|
352
|
+
'.tab{position:fixed;top:16px;right:16px;pointer-events:auto;display:flex;align-items:center;gap:7px;background:#2563eb;color:#fff;border:0;border-radius:999px;padding:9px 14px;font:600 13px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,0.22);}' +
|
|
353
|
+
/* cards */
|
|
354
|
+
'.card{position:fixed;pointer-events:auto;width:300px;max-width:calc(100vw - 24px);background:#fff;border:1px solid #e5e7eb;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,0.22);padding:14px;font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#1a1a1a;}' +
|
|
355
|
+
'.card h4{margin:0 0 8px;font-size:13px;color:#6b7280;font-weight:600;}' +
|
|
356
|
+
'.card .chead{display:flex;align-items:center;gap:8px;margin:0 0 8px;}' +
|
|
357
|
+
'.card .chead h4{margin:0;flex:1;}' +
|
|
358
|
+
/* scoped under .card so these beat the generic ".card button" (0,1,1) rule —
|
|
359
|
+
otherwise its padding/border-radius crushes the avatar to a 0-width box */
|
|
360
|
+
'.card .anno-toggle{flex:0 0 auto;width:26px;height:26px;min-width:0;border-radius:50%;border:2px solid #e5e7eb;background:#fff;padding:0;overflow:hidden;cursor:pointer;opacity:.4;filter:grayscale(1);transition:opacity .12s,filter .12s,border-color .12s;}' +
|
|
361
|
+
'.card .anno-toggle img{width:100%;height:100%;object-fit:cover;display:block;}' +
|
|
362
|
+
'.card .anno-toggle.on{opacity:1;filter:none;border-color:#f59e0b;}' +
|
|
363
|
+
'.msg{padding:8px 0;border-top:1px solid #f0f0f0;display:flex;gap:10px;align-items:flex-start;}' +
|
|
364
|
+
'.msg:first-of-type{border-top:0;}' +
|
|
365
|
+
'.msg .mav{flex:0 0 auto;width:28px;}' +
|
|
366
|
+
'.msg .mbody{min-width:0;flex:1;}' +
|
|
367
|
+
'.msg .who{font-weight:600;font-size:12px;}' +
|
|
368
|
+
'.msg .when{color:#9ca3af;font-size:11px;margin-left:6px;}' +
|
|
369
|
+
'.msg .body{margin-top:2px;white-space:pre-wrap;word-wrap:break-word;}' +
|
|
370
|
+
'.card input,.card textarea{width:100%;font:inherit;padding:8px 10px;border:1px solid #e5e7eb;border-radius:9px;margin-top:8px;background:#fafafa;color:#1a1a1a;}' +
|
|
371
|
+
'.card textarea{min-height:64px;resize:vertical;}' +
|
|
372
|
+
'.card .row{display:flex;gap:8px;margin-top:10px;align-items:center;justify-content:flex-end;}' +
|
|
373
|
+
'.card .row .spacer{flex:1;}' +
|
|
374
|
+
'.card button{font:inherit;cursor:pointer;border-radius:9px;padding:8px 14px;font-weight:500;border:1px solid #e5e7eb;background:#fff;color:#1a1a1a;}' +
|
|
375
|
+
'.card button.primary{background:#2563eb;color:#fff;border-color:transparent;}' +
|
|
376
|
+
'.card button.link{border:0;background:0;color:#6b7280;padding:8px 4px;}' +
|
|
377
|
+
'.card button.danger{border:0;background:0;color:#dc2626;padding:8px 4px;}' +
|
|
378
|
+
'.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);pointer-events:none;background:#1a1a1a;color:#fff;padding:8px 14px;border-radius:999px;font:13px -apple-system,BlinkMacSystemFont,sans-serif;opacity:0;transition:opacity .2s;}' +
|
|
379
|
+
'.toast.show{opacity:0.95;}' +
|
|
380
|
+
/* ===== compose + thread (see src/review/COMMENTING-UX.md) ===== */
|
|
381
|
+
/* progressive compose: a pin glyph + a field that reads as a grey pill when
|
|
382
|
+
idle and a white box once focused/typed-in. one textarea throughout so the
|
|
383
|
+
caret never jumps; auto-grown in JS. */
|
|
384
|
+
'.compose{position:fixed;pointer-events:auto;display:flex;align-items:flex-start;gap:9px;width:332px;max-width:calc(100vw - 24px);}' +
|
|
385
|
+
'.compose .cpin{flex:0 0 auto;width:26px;height:26px;border-radius:50% 50% 50% 2px;background:#2563eb;border:2px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.3);margin-top:2px;}' +
|
|
386
|
+
'.cfield{position:relative;flex:1;min-width:0;background:#fff;border:1px solid #e5e7eb;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,0.22);padding:6px;transition:border-radius .14s ease;}' +
|
|
387
|
+
'.cfield .nm{display:block;width:100%;font:inherit;font-size:13px;padding:7px 10px;border:0;border-bottom:1px solid #f0f0f0;border-radius:0;background:0;color:#1a1a1a;margin:0 0 2px;}' +
|
|
388
|
+
'.cfield .nm:focus{outline:0;}' +
|
|
389
|
+
'.cfield textarea{display:block;width:100%;border:0;outline:0;resize:none;font:inherit;color:#1a1a1a;background:0;margin:0;padding:8px 44px 8px 10px;min-height:0;max-height:180px;overflow-y:auto;line-height:1.45;}' +
|
|
390
|
+
'.cfield textarea::placeholder{color:#9ca3af;}' +
|
|
391
|
+
/* send arrow — disabled grey, enabled blue; pinned bottom-right of the field */
|
|
392
|
+
/* .send/.ico are scoped to win over the legacy ".card button" rule (0,1,1) */
|
|
393
|
+
'.compose .send,.card .send{position:absolute;right:8px;bottom:8px;width:30px;height:30px;border-radius:50%;border:0;padding:0;display:flex;align-items:center;justify-content:center;background:#e5e7eb;color:#fff;cursor:default;transition:background .12s ease;}' +
|
|
394
|
+
'.send svg{width:16px;height:16px;display:block;}' +
|
|
395
|
+
'.send.on{background:#2563eb;cursor:pointer;}' +
|
|
396
|
+
/* collapsed pill look: no shadow/border box, grey rounded bar */
|
|
397
|
+
'.cfield.idle{box-shadow:none;border-color:transparent;background:#f0f1f3;border-radius:999px;}' +
|
|
398
|
+
'.cfield.idle textarea{padding-top:7px;padding-bottom:7px;}' +
|
|
399
|
+
'.cfield.idle .send{background:transparent;color:#9ca3af;}' +
|
|
400
|
+
'.cfield.idle .send svg{stroke:#9ca3af;}' +
|
|
401
|
+
/* thread card header: title + right-aligned icon cluster */
|
|
402
|
+
'.thead{display:flex;align-items:center;gap:2px;margin:-2px 0 10px;}' +
|
|
403
|
+
'.thead strong{font-size:15px;font-weight:600;flex:1;}' +
|
|
404
|
+
'.card .ico{flex:0 0 auto;width:30px;height:30px;border-radius:50%;border:0;background:0;padding:0;display:flex;align-items:center;justify-content:center;color:#5b626e;cursor:pointer;}' +
|
|
405
|
+
'.ico:hover{background:#f0f1f3;}' +
|
|
406
|
+
'.ico svg{width:18px;height:18px;display:block;}' +
|
|
407
|
+
'.ico.res.done{color:#16a34a;}' +
|
|
408
|
+
/* the cat annotation toggle in the header */
|
|
409
|
+
'.ico.cat{overflow:hidden;opacity:.4;filter:grayscale(1);}' +
|
|
410
|
+
'.ico.cat img{width:22px;height:22px;border-radius:50%;object-fit:cover;display:block;}' +
|
|
411
|
+
'.ico.cat:hover{background:#f0f1f3;}' +
|
|
412
|
+
'.ico.cat.on{opacity:1;filter:none;}' +
|
|
413
|
+
/* messages */
|
|
414
|
+
'.msg .mhead{display:flex;align-items:baseline;gap:7px;}' +
|
|
415
|
+
'.msg .mhead .who{font-weight:600;font-size:14px;}' +
|
|
416
|
+
'.msg .mhead .when{color:#9ca3af;font-size:13px;flex:1;}' +
|
|
417
|
+
'.msg .mhead .mdel{flex:0 0 auto;width:24px;height:24px;border-radius:50%;border:0;background:0;color:#9ca3af;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .12s;}' +
|
|
418
|
+
'.msg:hover .mhead .mdel{opacity:1;}' +
|
|
419
|
+
'.msg .mhead .mdel:hover{background:#f0f1f3;color:#1a1a1a;}' +
|
|
420
|
+
'.msg .mhead .mdel svg{width:16px;height:16px;}' +
|
|
421
|
+
'.msg .body .mention{color:#2563eb;}' +
|
|
422
|
+
/* override the legacy separators — stack messages cleanly, no rules */
|
|
423
|
+
'.msgs .msg{border-top:0;padding:7px 0;}' +
|
|
424
|
+
'.msgs .msg:first-of-type{padding-top:0;}' +
|
|
425
|
+
'.msgs .msg .body{margin-top:3px;font-size:14px;line-height:1.5;}' +
|
|
426
|
+
/* reply bar: same pill→box behaviour as compose */
|
|
427
|
+
'.replybar{margin-top:6px;display:flex;gap:10px;align-items:center;}' +
|
|
428
|
+
'.replybar .rav{flex:0 0 auto;}' +
|
|
429
|
+
'.replybar .cfield{flex:1;min-width:0;}' +
|
|
430
|
+
/* Hover preview card. The pin doesn't get a card NEXT to it — the pin BECOMES the
|
|
431
|
+
card: a white bubble the exact size of the pin grows out into a full card around
|
|
432
|
+
the pin's own chip (a clone, so the morph is pixel-exact), and shrinks back into
|
|
433
|
+
it on leave. Width/height animate — never a scale, which would squash the type
|
|
434
|
+
while it travels. The body text is clipped by overflow until the box is wide
|
|
435
|
+
enough to hold it, so nothing reflows mid-flight. */
|
|
436
|
+
'.preview{position:fixed;pointer-events:none;box-sizing:border-box;max-width:calc(100vw - 24px);background:#fff;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,0.22);padding:13px 15px;opacity:0;visibility:hidden;overflow:hidden;display:flex;gap:10px;align-items:flex-start;cursor:pointer;transition:width .19s cubic-bezier(.22,.9,.28,1),height .19s cubic-bezier(.22,.9,.28,1),border-radius .19s ease,opacity .1s ease;}' +
|
|
437
|
+
'.preview.show{opacity:1;visibility:visible;pointer-events:auto;}' +
|
|
438
|
+
/* the pin's own clone, riding inside the card where the real pin was */
|
|
439
|
+
'.preview .pin.lead{position:static;transform:none;box-shadow:none;pointer-events:none;flex:0 0 auto;cursor:inherit;}' +
|
|
440
|
+
/* locked to a measured px width in JS: a flex-sized body would rewrap as the box grows */
|
|
441
|
+
'.preview .pbody{flex:0 0 auto;opacity:0;transition:opacity .12s ease;}' +
|
|
442
|
+
'.preview.grown .pbody{opacity:1;}' +
|
|
443
|
+
'.preview.left{flex-direction:row-reverse;}' +
|
|
444
|
+
'.preview .phead{display:flex;align-items:baseline;gap:7px;margin-bottom:3px;}' +
|
|
445
|
+
/* pname, not the .who every other surface uses: the lead chip is a cloned .pin.who,
|
|
446
|
+
so a `.who` here would resolve to the chip and write the author's name inside it */
|
|
447
|
+
'.preview .pname{font-weight:600;font-size:14px;}' +
|
|
448
|
+
'.preview .when{color:#9ca3af;font-size:13px;}' +
|
|
449
|
+
'.preview .body{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}' +
|
|
450
|
+
'.preview .body .mention{color:#2563eb;}' +
|
|
451
|
+
/* ===== Layer overlays — the "honesty" view. Each on-page canonical element is
|
|
452
|
+
classified by the composition graph (window.__GV_GRAPH, derived from the real
|
|
453
|
+
CSS) and boxed + badged by its LAYER. The badge carries a TEXT label (the layer
|
|
454
|
+
word + component name), so colour is never the only cue (WCAG 1.4.1). The box is
|
|
455
|
+
pointer-events:none so commenting passes through; only the badge is clickable,
|
|
456
|
+
opening the recursive import-chain panel down to live token values. ===== */
|
|
457
|
+
'.links{position:fixed;inset:0;pointer-events:none;}' +
|
|
458
|
+
'.linkbox{position:fixed;pointer-events:none;border:1.5px dashed rgba(94,106,210,0.5);background:rgba(94,106,210,0.07);border-radius:6px;}' +
|
|
459
|
+
'.linkbox.l-pattern{border-color:rgba(124,58,237,0.55);background:rgba(139,92,246,0.09);}' +
|
|
460
|
+
'.linkbox.l-component{border-color:rgba(86,114,218,0.6);background:rgba(86,114,218,0.07);}' +
|
|
461
|
+
'.linkbox.l-base{border-color:rgba(15,100,112,0.55);background:rgba(20,121,133,0.07);}' +
|
|
462
|
+
/* health overlay — overrides the layer tint when a box is off-grid or detached */
|
|
463
|
+
'.linkbox.h-offgrid{border-style:solid;border-color:rgba(229,72,77,0.85);background:rgba(229,72,77,0.08);}' +
|
|
464
|
+
'.linkbox.h-detached{border-style:dashed;border-color:rgba(240,180,41,0.9);background:rgba(240,180,41,0.10);}' +
|
|
465
|
+
'.linkbox.h-bespoke{border-style:solid;border-color:rgba(214,64,159,0.85);background:rgba(214,64,159,0.09);}' +
|
|
466
|
+
'.linkbox.h-modified{border-style:solid;border-color:rgba(232,89,12,0.85);background:rgba(232,89,12,0.09);}' +
|
|
467
|
+
'.linkbadge{position:absolute;top:0;left:8px;transform:translateY(-50%);pointer-events:auto;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:4px;background:#5672da;color:#fff;font:600 10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;letter-spacing:.02em;padding:3px 7px 3px 7px;border-radius:999px;box-shadow:0 1px 3px rgba(0,0,0,0.28);white-space:nowrap;transition:background .12s ease,transform .12s ease;}' +
|
|
468
|
+
'.linkbadge .lyr{opacity:.72;font-weight:700;text-transform:uppercase;letter-spacing:.04em;}' +
|
|
469
|
+
'.linkbadge.l-pattern{background:#7c3aed;}.linkbadge.l-component{background:#5672da;}.linkbadge.l-base{background:#147985;}' +
|
|
470
|
+
/* health chip on the badge: linked (good) · detached (forked) · off-grid (violation) */
|
|
471
|
+
'.linkbadge .hb{margin-left:5px;padding:1px 5px 1px 5px;border-radius:999px;font:700 9px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;text-transform:uppercase;letter-spacing:.03em;}' +
|
|
472
|
+
'.linkbadge .hb.linked{background:rgba(255,255,255,0.22);color:#fff;}' +
|
|
473
|
+
'.linkbadge .hb.detached{background:#f0b429;color:#3a2c00;}' +
|
|
474
|
+
'.linkbadge .hb.offgrid{background:#e5484d;color:#fff;}' +
|
|
475
|
+
'.linkbadge .hb.bespoke{background:#d6409f;color:#fff;}' +
|
|
476
|
+
'.linkbadge .hb.modified{background:#e8590c;color:#fff;}' +
|
|
477
|
+
'.linkbadge.h-offgrid{box-shadow:0 0 0 2px rgba(229,72,77,0.85),0 1px 3px rgba(0,0,0,0.28);}' +
|
|
478
|
+
'.linkbadge.h-detached{box-shadow:0 0 0 2px rgba(240,180,41,0.9),0 1px 3px rgba(0,0,0,0.28);}' +
|
|
479
|
+
'.linkbadge.h-bespoke{box-shadow:0 0 0 2px rgba(214,64,159,0.85),0 1px 3px rgba(0,0,0,0.28);}' +
|
|
480
|
+
'.linkbadge.h-modified{box-shadow:0 0 0 2px rgba(232,89,12,0.85),0 1px 3px rgba(0,0,0,0.28);}' +
|
|
481
|
+
'.linkbadge:hover{transform:translateY(-50%) scale(1.06);filter:brightness(1.08);}' +
|
|
482
|
+
'.linkbadge svg{width:10px;height:10px;display:block;flex:0 0 auto;}' +
|
|
483
|
+
/* spacing shading (devtools-style) — drawn inside each box at the "+ Tokens" drill
|
|
484
|
+
level: padding ring (teal), gaps between flex/grid children (purple), top/bottom
|
|
485
|
+
margin (amber, outside the box). Each strip carries a px label so the actual
|
|
486
|
+
resolved spacing is readable without opening the chain panel. */
|
|
487
|
+
'.spc{position:absolute;pointer-events:none;display:flex;align-items:center;justify-content:center;overflow:visible;box-sizing:border-box;z-index:1;}' +
|
|
488
|
+
'.spcpad{background:rgba(20,121,133,0.24);}' +
|
|
489
|
+
'.spcgap{background:rgba(124,58,237,0.24);outline:1px dashed rgba(124,58,237,0.55);outline-offset:-1px;}' +
|
|
490
|
+
'.spcmar{background:rgba(240,150,40,0.22);}' +
|
|
491
|
+
'.spclbl{font:700 9px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#fff;background:rgba(0,0,0,0.62);padding:1px 3px;border-radius:3px;white-space:nowrap;letter-spacing:.02em;}' +
|
|
492
|
+
/* drill control — bottom-left pill cycling Components → +Base → +Tokens */
|
|
493
|
+
'.drillctl{position:fixed;left:16px;bottom:16px;pointer-events:auto;display:inline-flex;align-items:center;gap:7px;background:#16171a;color:#fff;border:0;border-radius:999px;padding:8px 14px;font:600 12px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,0.28);}' +
|
|
494
|
+
'.drillctl .dot{width:7px;height:7px;border-radius:50%;background:#7c3aed;box-shadow:0 0 0 2px rgba(255,255,255,.25);}' +
|
|
495
|
+
'.drillctl b{font-weight:700;}' +
|
|
496
|
+
/* import-chain panel — the recursive layer inspector */
|
|
497
|
+
'.chainp{position:fixed;left:16px;bottom:60px;width:330px;max-width:calc(100vw - 32px);max-height:72vh;overflow:auto;pointer-events:auto;background:#fff;border:1px solid #e5e7eb;border-radius:14px;box-shadow:0 16px 50px rgba(0,0,0,0.26);padding:0;font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#1a1a1a;}' +
|
|
498
|
+
'.chainp__h{position:sticky;top:0;background:#fff;border-bottom:1px solid #f0f0f0;border-radius:14px 14px 0 0;padding:13px 14px 11px;display:flex;align-items:flex-start;gap:8px;}' +
|
|
499
|
+
'.chainp__h .ttl{flex:1;min-width:0;}' +
|
|
500
|
+
'.chainp__h .nm{font-weight:700;font-size:14px;display:block;}' +
|
|
501
|
+
'.chip{display:inline-flex;align-items:center;gap:4px;font:700 9.5px/1 -apple-system,BlinkMacSystemFont,sans-serif;text-transform:uppercase;letter-spacing:.04em;color:#fff;padding:3px 7px;border-radius:999px;margin-top:4px;}' +
|
|
502
|
+
'.chip.l-pattern{background:#7c3aed;}.chip.l-component{background:#5672da;}.chip.l-base{background:#147985;}.chip.l-token{background:#0e0f12;}' +
|
|
503
|
+
'.chainp__x{flex:0 0 auto;width:26px;height:26px;border:0;background:0;color:#9ca3af;cursor:pointer;border-radius:50%;font-size:17px;line-height:1;}' +
|
|
504
|
+
'.chainp__x:hover{background:#f0f1f3;color:#1a1a1a;}' +
|
|
505
|
+
'.chainp__jump{display:block;margin:0 14px 10px;text-align:center;text-decoration:none;background:#f3f4f7;color:#16171a;font-weight:600;font-size:12px;padding:8px;border-radius:9px;}' +
|
|
506
|
+
'.chainp__jump:hover{background:#e9ebf0;}' +
|
|
507
|
+
'.chainp__sec{padding:10px 14px;border-top:1px solid #f4f4f6;}' +
|
|
508
|
+
'.chainp__sec h5{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#9ca3af;font-weight:700;}' +
|
|
509
|
+
'.chainrow{display:flex;align-items:center;gap:8px;width:100%;text-align:left;border:0;background:0;padding:6px 7px;border-radius:8px;cursor:pointer;font:inherit;color:#1a1a1a;}' +
|
|
510
|
+
'.chainrow:hover{background:#f5f6f8;}' +
|
|
511
|
+
'.chainrow .cn{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}' +
|
|
512
|
+
'.chainrow .cn code{font-size:11.5px;color:#4650b8;}' +
|
|
513
|
+
'.chainrow .arr{color:#c2c6cf;flex:0 0 auto;}' +
|
|
514
|
+
'.tokrow{display:flex;align-items:center;gap:8px;padding:5px 7px;}' +
|
|
515
|
+
'.tokrow .sw{flex:0 0 auto;width:18px;height:18px;border-radius:5px;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.14);}' +
|
|
516
|
+
'.tokrow .tk{min-width:0;flex:1;}' +
|
|
517
|
+
'.tokrow .tk code{font-size:11px;color:#1a1a1a;word-break:break-all;}' +
|
|
518
|
+
'.tokrow .tk .res{display:block;color:#6b7280;font-size:10.5px;margin-top:1px;}' +
|
|
519
|
+
'.tokrow .tk .res b{color:#4650b8;font-weight:600;}' +
|
|
520
|
+
'.chainp__empty{padding:8px 14px 14px;color:#9ca3af;font-size:12px;}' +
|
|
521
|
+
'</style>' +
|
|
522
|
+
'<div class="layer hidden">' +
|
|
523
|
+
' <div class="links"></div>' +
|
|
524
|
+
' <button class="drillctl hidden" type="button" title="Layers — click or press ↑/↓ to change depth (Components → + Base → + Tokens shows spacing)"><span class="dot"></span>Layers: <b></b></button>' +
|
|
525
|
+
' <div class="chainp hidden"></div>' +
|
|
526
|
+
' <div class="pins"></div>' +
|
|
527
|
+
' <aside class="sb">' +
|
|
528
|
+
' <header><strong>Comments</strong><span class="cnt"></span>' +
|
|
529
|
+
' <button class="newc" title="New comment">+</button>' +
|
|
530
|
+
' <button class="collapse" title="Collapse">›</button></header>' +
|
|
531
|
+
' <div class="hint"></div>' +
|
|
532
|
+
' <ul class="list"></ul>' +
|
|
533
|
+
' </aside>' +
|
|
534
|
+
' <button class="tab hidden">💬 <span class="cnt2"></span></button>' +
|
|
535
|
+
' <div class="cardholder"></div>' +
|
|
536
|
+
' <div class="atip"></div>' +
|
|
537
|
+
' <div class="preview"></div>' +
|
|
538
|
+
' <div class="toast"></div>' +
|
|
539
|
+
'</div>';
|
|
540
|
+
|
|
541
|
+
var $ = function (s) { return root.querySelector(s); };
|
|
542
|
+
var layer = $(".layer"), pinsEl = $(".pins"), linksEl = $(".links"),
|
|
543
|
+
sb = $(".sb"), listEl = $(".list"), cntEl = $(".cnt"), cnt2El = $(".cnt2"),
|
|
544
|
+
hintEl = $(".hint"), tabEl = $(".tab"), cardholder = $(".cardholder"),
|
|
545
|
+
tipEl = $(".atip"), previewEl = $(".preview"), toastEl = $(".toast"),
|
|
546
|
+
drillEl = $(".drillctl"), chainEl = $(".chainp");
|
|
547
|
+
|
|
548
|
+
// Shared SVG glyphs for the overlay chrome.
|
|
549
|
+
var SVG = {
|
|
550
|
+
send: '<svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M12 5l-6 6M12 5l6 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
551
|
+
dots: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
|
|
552
|
+
check: '<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/><path d="M8.5 12.2l2.4 2.4 4.6-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
553
|
+
close: '<svg viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
var sbCollapsed = false;
|
|
557
|
+
try { sbCollapsed = localStorage.getItem(LS_SB) === "1"; } catch (e) {}
|
|
558
|
+
|
|
559
|
+
/* ---------- rendering ---------- */
|
|
560
|
+
|
|
561
|
+
function render() {
|
|
562
|
+
// The layer itself is ALWAYS live — annotation pins must show with review off.
|
|
563
|
+
// Only the review chrome (sidebar, tab) is gated by state.active.
|
|
564
|
+
layer.classList.remove("hidden");
|
|
565
|
+
var on = state.active;
|
|
566
|
+
placeCursor(placing());
|
|
567
|
+
sb.classList.toggle("hidden", !on);
|
|
568
|
+
sb.classList.toggle("collapsed", sbCollapsed);
|
|
569
|
+
tabEl.classList.toggle("hidden", !on || !sbCollapsed);
|
|
570
|
+
if (!on) { closeCard(); }
|
|
571
|
+
hintEl.textContent = state.mode === "add"
|
|
572
|
+
? "Click anywhere to place a comment · Esc to browse"
|
|
573
|
+
: "Drag a pin to move it · + for a new comment";
|
|
574
|
+
renderList();
|
|
575
|
+
renderPins();
|
|
576
|
+
renderLinks();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function listed() { return state.threads.filter(isListed); }
|
|
580
|
+
// Pins shown on the canvas: everything in review mode, only annotations when off.
|
|
581
|
+
function pinThreads() { return state.active ? listed() : listed().filter(isAnno); }
|
|
582
|
+
|
|
583
|
+
function renderList() {
|
|
584
|
+
var items = listed();
|
|
585
|
+
var open = items.filter(function (t) { return !t.resolved; }).length;
|
|
586
|
+
cntEl.textContent = items.length ? open + " open" + (items.length - open ? " · " + (items.length - open) + " done" : "") : "";
|
|
587
|
+
cnt2El.textContent = items.length || "";
|
|
588
|
+
listEl.textContent = "";
|
|
589
|
+
if (!items.length) {
|
|
590
|
+
var e = document.createElement("li");
|
|
591
|
+
e.className = "empty";
|
|
592
|
+
e.textContent = "No comments yet. Click the prototype to add one.";
|
|
593
|
+
listEl.appendChild(e);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
items.forEach(function (t, i) {
|
|
597
|
+
var li = document.createElement("li");
|
|
598
|
+
var anno = isAnno(t);
|
|
599
|
+
li.className = "it" + (t.resolved ? " resolved" : "") + (anno ? " anno" : "") + (state.openId === t.id ? " active" : "");
|
|
600
|
+
var away = !resolvesHere(t);
|
|
601
|
+
li.innerHTML = '<span class="num"></span><span class="txt"><span class="body"></span>' +
|
|
602
|
+
'<span class="meta"></span></span>';
|
|
603
|
+
if (anno) {
|
|
604
|
+
var av = document.createElement("img"); av.src = CAT; av.alt = "";
|
|
605
|
+
var n = li.querySelector(".num"); n.textContent = ""; n.appendChild(av);
|
|
606
|
+
} else {
|
|
607
|
+
var lp = authorOf(t);
|
|
608
|
+
if (lp) {
|
|
609
|
+
var n2 = li.querySelector(".num");
|
|
610
|
+
n2.textContent = ""; n2.className = "num face";
|
|
611
|
+
n2.appendChild(avatarEl(lp, 20));
|
|
612
|
+
} else {
|
|
613
|
+
li.querySelector(".num").textContent = String(i + 1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
li.querySelector(".body").textContent = (t.messages[0] && t.messages[0].body) || "(empty)";
|
|
617
|
+
li.querySelector(".meta").textContent = (anno ? "Annotation · " : "") + (t.messages[0] ? t.messages[0].author : "") +
|
|
618
|
+
(away ? " · ↗ on another screen" : "") + (t.resolved ? " · resolved" : "");
|
|
619
|
+
if (away) li.querySelector(".meta").className = "meta away";
|
|
620
|
+
li.addEventListener("click", function () { openOrNavigate(t.id); });
|
|
621
|
+
listEl.appendChild(li);
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Initials + a stable colour from a name alone, for a verified author we could not
|
|
626
|
+
// resolve — /__people unreachable, or the localStorage fallback path with no server
|
|
627
|
+
// at all. A known person must never collapse back to an anonymous number.
|
|
628
|
+
var AV_COLORS = ["#4f46e5", "#0e7490", "#b45309", "#be123c", "#15803d", "#7c3aed", "#0369a1", "#a21caf"];
|
|
629
|
+
function fromName(name) {
|
|
630
|
+
var parts = String(name).trim().split(/\s+/).filter(Boolean);
|
|
631
|
+
var ini = !parts.length ? "?"
|
|
632
|
+
: (parts.length === 1 ? parts[0].slice(0, 2) : parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
633
|
+
var h = 0, s = String(name).trim().toLowerCase();
|
|
634
|
+
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
635
|
+
return { id: null, name: name, initials: ini, color: AV_COLORS[h % AV_COLORS.length], avatar: null };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// null → this comment gets today's numbered blue pin. Order matters: an id beats a
|
|
639
|
+
// name, and an UNVERIFIED name resolves to nothing at all — an anonymous commenter
|
|
640
|
+
// never earns a face, however they signed themselves.
|
|
641
|
+
function personFor(m) {
|
|
642
|
+
if (!m) return null;
|
|
643
|
+
if (m.by && PEOPLE[m.by]) return PEOPLE[m.by];
|
|
644
|
+
if (!m.verified || !m.author) return null;
|
|
645
|
+
if (BYNAME[m.author]) return BYNAME[m.author];
|
|
646
|
+
// Verified, but the roster is unavailable or hasn't answered yet.
|
|
647
|
+
return fromName(m.author);
|
|
648
|
+
}
|
|
649
|
+
function authorOf(t) { return personFor(t && t.messages && t.messages[0]); }
|
|
650
|
+
|
|
651
|
+
// The one avatar implementation every surface uses: photo when there is one,
|
|
652
|
+
// initials on the person's colour when there isn't. Never a silhouette.
|
|
653
|
+
function avatarEl(p, size) {
|
|
654
|
+
var e;
|
|
655
|
+
if (p && p.avatar) {
|
|
656
|
+
e = document.createElement("img");
|
|
657
|
+
e.src = p.avatar;
|
|
658
|
+
e.alt = "";
|
|
659
|
+
// A dead photo URL must degrade to initials, not a broken-image glyph.
|
|
660
|
+
e.addEventListener("error", function () {
|
|
661
|
+
var f = initialsEl(p, size);
|
|
662
|
+
if (e.parentNode) e.parentNode.replaceChild(f, e);
|
|
663
|
+
});
|
|
664
|
+
} else {
|
|
665
|
+
e = initialsEl(p, size);
|
|
666
|
+
}
|
|
667
|
+
e.className = "av";
|
|
668
|
+
e.style.width = size + "px";
|
|
669
|
+
e.style.height = size + "px";
|
|
670
|
+
return e;
|
|
671
|
+
}
|
|
672
|
+
function initialsEl(p, size) {
|
|
673
|
+
var s = document.createElement("span");
|
|
674
|
+
s.className = "av ini";
|
|
675
|
+
s.textContent = (p && p.initials) || "?";
|
|
676
|
+
s.style.background = (p && p.color) || "#6b7280";
|
|
677
|
+
s.style.fontSize = Math.max(9, Math.round(size * 0.4)) + "px";
|
|
678
|
+
s.style.width = size + "px";
|
|
679
|
+
s.style.height = size + "px";
|
|
680
|
+
return s;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function renderPins() {
|
|
684
|
+
pinsEl.textContent = "";
|
|
685
|
+
// Number by position in the full listed() set so pin N matches sidebar item N.
|
|
686
|
+
var nums = {}; listed().forEach(function (t, i) { nums[t.id] = i + 1; });
|
|
687
|
+
pinThreads().forEach(function (t) {
|
|
688
|
+
var xy = pinXY(t);
|
|
689
|
+
if (!xy) return; // elsewhere → sidebar only
|
|
690
|
+
var b = document.createElement("button");
|
|
691
|
+
var anno = isAnno(t);
|
|
692
|
+
b.className = "pin" + (anno ? " anno" : "") + (t.resolved ? " resolved" : "") + (state.openId === t.id ? " active" : "");
|
|
693
|
+
b.style.left = xy.x + "px"; b.style.top = xy.y + "px";
|
|
694
|
+
if (anno) {
|
|
695
|
+
var av = document.createElement("img"); av.src = CAT; av.alt = "";
|
|
696
|
+
b.appendChild(av);
|
|
697
|
+
b.style.setProperty("--rot", annoRot(t.id) + "deg");
|
|
698
|
+
b.addEventListener("mouseenter", function () { showTip(b, t.id); });
|
|
699
|
+
b.addEventListener("mouseleave", hideTip);
|
|
700
|
+
} else {
|
|
701
|
+
var who = authorOf(t);
|
|
702
|
+
if (who) {
|
|
703
|
+
b.classList.add("who");
|
|
704
|
+
// 24, not 28: .pin.who is a 28px border-box with a 2px border, so its
|
|
705
|
+
// padding box — what overflow:hidden clips to — is 24px square.
|
|
706
|
+
b.appendChild(avatarEl(who, 24));
|
|
707
|
+
b.title = who.name;
|
|
708
|
+
} else {
|
|
709
|
+
b.textContent = String(nums[t.id] || "");
|
|
710
|
+
}
|
|
711
|
+
// Hover preview card that grows out of the pin (review mode only).
|
|
712
|
+
(function (btn, tid) {
|
|
713
|
+
btn.addEventListener("mouseenter", function () {
|
|
714
|
+
if (state.openId === tid) return;
|
|
715
|
+
queuePreview(btn, tid);
|
|
716
|
+
});
|
|
717
|
+
btn.addEventListener("mouseleave", queueHidePreview);
|
|
718
|
+
})(b, t.id);
|
|
719
|
+
}
|
|
720
|
+
attachPinDrag(b, t.id);
|
|
721
|
+
b.__tid = t.id;
|
|
722
|
+
pinsEl.appendChild(b);
|
|
723
|
+
});
|
|
724
|
+
rebindPreview();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// A re-render throws away the very element the open hover card is standing in for
|
|
728
|
+
// (and the "under" that hides it), which would leave the pin painted twice — once
|
|
729
|
+
// as itself, once as the card's chip. Re-attach to the new element, or give up.
|
|
730
|
+
function rebindPreview() {
|
|
731
|
+
if (!previewId || !previewEl.classList.contains("show")) return;
|
|
732
|
+
var found = null, kids = pinsEl.children;
|
|
733
|
+
for (var i = 0; i < kids.length; i++) if (kids[i].__tid === previewId) found = kids[i];
|
|
734
|
+
if (!found) { dropPreview(); return; }
|
|
735
|
+
previewBtn = found;
|
|
736
|
+
if (previewSmall) found.classList.add("under");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/* ---------- Recursive layer overlay (the honesty view) ----------
|
|
740
|
+
* Driven by window.__GV_GRAPH (graph.js, DERIVED from the canonical CSS at build) +
|
|
741
|
+
* window.__GV_LINKED (which canonical assets this page is in sync with). Every
|
|
742
|
+
* on-page canonical element is classified by the graph into a LAYER (base/component/
|
|
743
|
+
* pattern); the badge proves the import chain by recursing into the panel:
|
|
744
|
+
* pattern → its components → their base atoms → the tokens each one drinks (with
|
|
745
|
+
* LIVE resolved values from getComputedStyle, so tenant theming shows through).
|
|
746
|
+
* A drilldown control reveals deeper layers; a forked/drifted dependency drops out
|
|
747
|
+
* because its deps aren't in __GV_LINKED. This is the inverse of asserting links: it
|
|
748
|
+
* recomputes them from the real CSS graph every render. */
|
|
749
|
+
var GRAPH = window.__GV_GRAPH || { classes: {}, tokens: {} };
|
|
750
|
+
var LAYER_RANK = { base: 1, component: 2, components: 2, pattern: 3 };
|
|
751
|
+
var DRILL = ["Components", "+ Base", "+ Tokens"]; // 0,1,2
|
|
752
|
+
function normLayer(l) { return l === "components" ? "component" : l; }
|
|
753
|
+
function famRoot(cls) { return cls.replace(/__.*/, "").replace(/--.*/, ""); }
|
|
754
|
+
function depsMet(deps) {
|
|
755
|
+
var L = window.__GV_LINKED;
|
|
756
|
+
if (!L || !L.length || !deps) return false;
|
|
757
|
+
for (var i = 0; i < deps.length; i++) if (L.indexOf(deps[i]) < 0) return false;
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
// Best (highest-layer, deps-met) graph classification for one element, or null.
|
|
761
|
+
function classifyEl(el) {
|
|
762
|
+
if (!el.classList || !el.classList.length) return null;
|
|
763
|
+
var best = null;
|
|
764
|
+
for (var i = 0; i < el.classList.length; i++) {
|
|
765
|
+
var c = el.classList[i];
|
|
766
|
+
// The graph's own keys are the vocabulary — any class whose family root it
|
|
767
|
+
// knows can badge, whatever prefix the space's design system chose.
|
|
768
|
+
// Only the family ROOT (or a --modifier of it) marks a component root; a BEM
|
|
769
|
+
// __part (e.g. .gv-feed__head) is internal scaffolding, not its own root, so it
|
|
770
|
+
// must not badge as the whole component or it spawns phantom duplicate roots.
|
|
771
|
+
if (c.indexOf("__") >= 0) continue;
|
|
772
|
+
var fam = famRoot(c), info = GRAPH.classes[fam];
|
|
773
|
+
if (!info || !info.layer || !info.label) continue; // only labelled families badge
|
|
774
|
+
if (!depsMet(info.deps)) continue; // honesty: drifted → no badge
|
|
775
|
+
var rank = LAYER_RANK[info.layer] || 0;
|
|
776
|
+
if (!best || rank > best.rank) best = { family: fam, info: info, layer: normLayer(info.layer), rank: rank };
|
|
777
|
+
}
|
|
778
|
+
return best;
|
|
779
|
+
}
|
|
780
|
+
// The elements to box at the current drill level: outermost composites always,
|
|
781
|
+
// plus outermost base atoms once drilled in.
|
|
782
|
+
function collectLayered() {
|
|
783
|
+
var all = [], els = document.querySelectorAll("[class]");
|
|
784
|
+
for (var i = 0; i < els.length; i++) {
|
|
785
|
+
if (host.contains(els[i])) continue;
|
|
786
|
+
var c = classifyEl(els[i]); if (!c) continue;
|
|
787
|
+
c.el = els[i]; all.push(c);
|
|
788
|
+
}
|
|
789
|
+
var outermost = function (list) {
|
|
790
|
+
return list.filter(function (a) {
|
|
791
|
+
return !list.some(function (b) { return b.el !== a.el && b.el.contains(a.el); });
|
|
792
|
+
});
|
|
793
|
+
};
|
|
794
|
+
var composites = outermost(all.filter(function (a) { return a.rank >= 2; }));
|
|
795
|
+
var show = composites.slice();
|
|
796
|
+
if (state.drill >= 1) {
|
|
797
|
+
var bases = outermost(all.filter(function (a) { return a.rank === 1; }));
|
|
798
|
+
show = show.concat(bases);
|
|
799
|
+
}
|
|
800
|
+
return show;
|
|
801
|
+
}
|
|
802
|
+
var LINKICON = '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 12h6M9.5 8H7a4 4 0 0 0 0 8h2.5M14.5 8H17a4 4 0 0 1 0 8h-2.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
803
|
+
|
|
804
|
+
/* ---------- health: linked / detached / bespoke / modified / off-grid ----------
|
|
805
|
+
* The badge already proves LINKAGE (it only renders for a deps-met canonical family).
|
|
806
|
+
* Health adds the second axis (right vs off-grid), shown at the same time as the layer:
|
|
807
|
+
* • off-grid — the box's own computed SPACING sits off the space scale. The scale is
|
|
808
|
+
* the 8-point grid with a 4-point half-step, so on-grid = any MULTIPLE OF 4. We read
|
|
809
|
+
* getComputedStyle and check padding (all sides), row/column gap, and TOP/BOTTOM
|
|
810
|
+
* margin only — left/right margins are skipped because `margin:auto` centering
|
|
811
|
+
* resolves to arbitrary px and would false-positive. Scope matches lint INV-9:
|
|
812
|
+
* positive integer px ≥4 not divisible by 4.
|
|
813
|
+
* • detached — a deliberately forked instance, flagged by data-gv-detached on the
|
|
814
|
+
* element (or window.__GV_DETACHED listing the family). Detach lands in Phase 4;
|
|
815
|
+
* this lights up automatically once it does.
|
|
816
|
+
* • linked — neither of the above (the good default). */
|
|
817
|
+
function pxOffGrid(v) {
|
|
818
|
+
var m = /^(-?\d*\.?\d+)px$/.exec(v); if (!m) return false;
|
|
819
|
+
var n = parseFloat(m[1]);
|
|
820
|
+
return Number.isInteger(n) && n >= 4 && n % 4 !== 0;
|
|
821
|
+
}
|
|
822
|
+
function spacingHealth(el) {
|
|
823
|
+
var cs; try { cs = getComputedStyle(el); } catch (e) { return null; }
|
|
824
|
+
var props = ["paddingTop","paddingRight","paddingBottom","paddingLeft","rowGap","columnGap","marginTop","marginBottom"];
|
|
825
|
+
var hits = [];
|
|
826
|
+
for (var i = 0; i < props.length; i++) { if (pxOffGrid(cs[props[i]])) hits.push(props[i].replace(/([A-Z])/g, "-$1").toLowerCase() + ":" + cs[props[i]]); }
|
|
827
|
+
return hits.length ? hits : null;
|
|
828
|
+
}
|
|
829
|
+
function isDetached(el, fam) {
|
|
830
|
+
if (el.hasAttribute && el.hasAttribute("data-gv-detached")) return true;
|
|
831
|
+
var D = window.__GV_DETACHED;
|
|
832
|
+
return !!(D && D.length && D.indexOf(fam) >= 0);
|
|
833
|
+
}
|
|
834
|
+
/* Provenance — the honesty signal class-lineage alone can't give. The runtime stamps
|
|
835
|
+
* data-gv-rendered (+ data-gv-hash) on a host it actually rendered (GV.mountAll/mount).
|
|
836
|
+
* A composite (component/pattern, rank≥2) NOT inside such a host wears the .gv-* class
|
|
837
|
+
* but was hand-authored → "bespoke". One that IS inside a rendered host but whose live
|
|
838
|
+
* markup no longer hashes to the stamp was edited after mount → "modified". Base atoms
|
|
839
|
+
* (rank 1) are meant to be hand-written, so they're exempt. */
|
|
840
|
+
function provenanceOf(el, rank) {
|
|
841
|
+
if (!(rank >= 2) || !el.closest) return null;
|
|
842
|
+
var host = el.closest("[data-gv-rendered]");
|
|
843
|
+
if (!host) return { state: "bespoke", detail: "bespoke — hand-authored markup wearing the class, not a canonical instance" };
|
|
844
|
+
var stored = host.getAttribute("data-gv-hash"), fn = (window.GV && GV.hash);
|
|
845
|
+
if (stored && fn && fn(host.innerHTML) !== stored) return { state: "modified", detail: "modified — a canonical instance edited after it was rendered" };
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
function healthOf(el, fam, rank) {
|
|
849
|
+
if (isDetached(el, fam)) return { state: "detached", detail: "detached — forked from canonical" };
|
|
850
|
+
var prov = provenanceOf(el, rank);
|
|
851
|
+
if (prov) return prov;
|
|
852
|
+
var off = spacingHealth(el);
|
|
853
|
+
if (off) return { state: "offgrid", detail: "off-grid spacing: " + off.join(", ") };
|
|
854
|
+
return { state: "linked", detail: "linked — tracks canonical, on-grid" };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
var linkBoxes = []; // {box, el}
|
|
858
|
+
function renderLinks() {
|
|
859
|
+
linksEl.textContent = ""; linkBoxes = [];
|
|
860
|
+
drillEl.classList.toggle("hidden", !state.active);
|
|
861
|
+
if (!state.active) { closeChain(); return; } // overlays only while review mode is on
|
|
862
|
+
drillEl.querySelector("b").textContent = DRILL[state.drill] || DRILL[0];
|
|
863
|
+
collectLayered().forEach(function (it) {
|
|
864
|
+
var r = it.el.getBoundingClientRect();
|
|
865
|
+
if (r.width < 8 || r.height < 8) return; // hidden / collapsed
|
|
866
|
+
var h = healthOf(it.el, it.family, it.rank);
|
|
867
|
+
var box = document.createElement("div");
|
|
868
|
+
box.className = "linkbox l-" + slugClass(it.layer) + " h-" + slugClass(h.state);
|
|
869
|
+
if (state.drill >= 2) drawSpacing(box, it.el); // append shades first → badge stays on top
|
|
870
|
+
var badge = document.createElement("button");
|
|
871
|
+
badge.type = "button";
|
|
872
|
+
badge.className = "linkbadge l-" + slugClass(it.layer) + " h-" + slugClass(h.state);
|
|
873
|
+
badge.title = it.layer + " · " + it.info.label + " · " + h.detail + " — open the import chain";
|
|
874
|
+
badge.innerHTML = LINKICON + '<span class="lyr">' + escHtml(it.layer) + '</span> ' + escHtml(it.info.label) +
|
|
875
|
+
'<span class="hb ' + slugClass(h.state) + '">' + escHtml(h.state) + '</span>';
|
|
876
|
+
(function (fam, el) {
|
|
877
|
+
badge.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); openChain(fam, el); });
|
|
878
|
+
})(it.family, it.el);
|
|
879
|
+
box.appendChild(badge);
|
|
880
|
+
linksEl.appendChild(box);
|
|
881
|
+
linkBoxes.push({ box: box, el: it.el });
|
|
882
|
+
});
|
|
883
|
+
positionLinks();
|
|
884
|
+
}
|
|
885
|
+
function positionLinks() {
|
|
886
|
+
for (var i = 0; i < linkBoxes.length; i++) {
|
|
887
|
+
var lb = linkBoxes[i], r = lb.el.getBoundingClientRect();
|
|
888
|
+
if (r.width < 8 || r.height < 8) { lb.box.style.display = "none"; continue; }
|
|
889
|
+
lb.box.style.display = "";
|
|
890
|
+
lb.box.style.left = r.left + "px"; lb.box.style.top = r.top + "px";
|
|
891
|
+
lb.box.style.width = r.width + "px"; lb.box.style.height = r.height + "px";
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/* ---------- spacing shading (the "+ Tokens" devtools view) ----------
|
|
896
|
+
* At drill ≥ 2 each rendered box gets its real resolved spacing painted ON the box:
|
|
897
|
+
* the padding ring, the gaps between flex/grid children, and top/bottom margin. All
|
|
898
|
+
* offsets are RELATIVE to the box's border-box origin (its left/top), so the shading
|
|
899
|
+
* tracks the box for free on scroll — no per-frame recompute. Resize rebuilds (layout
|
|
900
|
+
* may reflow). Reads getComputedStyle so tenant theming / responsive values show real. */
|
|
901
|
+
function spx(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; }
|
|
902
|
+
function shade(box, cls, l, t, w, h, label) {
|
|
903
|
+
if (w <= 0 || h <= 0) return;
|
|
904
|
+
var d = document.createElement("div");
|
|
905
|
+
d.className = "spc " + cls;
|
|
906
|
+
d.style.left = l + "px"; d.style.top = t + "px"; d.style.width = w + "px"; d.style.height = h + "px";
|
|
907
|
+
if (label != null) { var s = document.createElement("span"); s.className = "spclbl"; s.textContent = label; d.appendChild(s); }
|
|
908
|
+
box.appendChild(d);
|
|
909
|
+
}
|
|
910
|
+
function drawGaps(box, el, cs, rect) {
|
|
911
|
+
var disp = cs.display || "";
|
|
912
|
+
if (disp.indexOf("flex") < 0 && disp.indexOf("grid") < 0) return;
|
|
913
|
+
var kids = [], ch = el.children;
|
|
914
|
+
for (var i = 0; i < ch.length; i++) {
|
|
915
|
+
if (host.contains(ch[i])) continue;
|
|
916
|
+
var cr = ch[i].getBoundingClientRect();
|
|
917
|
+
if (cr.width >= 1 && cr.height >= 1) kids.push(cr);
|
|
918
|
+
}
|
|
919
|
+
for (var j = 0; j < kids.length - 1; j++) {
|
|
920
|
+
var a = kids[j], b = kids[j + 1];
|
|
921
|
+
var hg = b.left - a.right, vg = b.top - a.bottom;
|
|
922
|
+
if (hg > 0 && hg < 200 && (vg <= 0 || hg >= vg)) { // column gap (row layout)
|
|
923
|
+
var top = Math.max(a.top, b.top), bot = Math.min(a.bottom, b.bottom);
|
|
924
|
+
shade(box, "spcgap", a.right - rect.left, top - rect.top, hg, Math.max(bot - top, 1), Math.round(hg));
|
|
925
|
+
} else if (vg > 0 && vg < 200) { // row gap (column layout)
|
|
926
|
+
var lft = Math.max(a.left, b.left), rgt = Math.min(a.right, b.right);
|
|
927
|
+
shade(box, "spcgap", lft - rect.left, a.bottom - rect.top, Math.max(rgt - lft, 1), vg, Math.round(vg));
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function drawSpacing(box, el) {
|
|
932
|
+
var cs; try { cs = getComputedStyle(el); } catch (e) { return; }
|
|
933
|
+
var rect = el.getBoundingClientRect();
|
|
934
|
+
var W = rect.width, H = rect.height;
|
|
935
|
+
var bt = spx(cs.borderTopWidth), br = spx(cs.borderRightWidth), bb = spx(cs.borderBottomWidth), bl = spx(cs.borderLeftWidth);
|
|
936
|
+
var pt = spx(cs.paddingTop), pr = spx(cs.paddingRight), pb = spx(cs.paddingBottom), pl = spx(cs.paddingLeft);
|
|
937
|
+
var iw = W - bl - br, ih = H - bt - bb; // padding-box (border-box minus borders)
|
|
938
|
+
// padding ring
|
|
939
|
+
if (pt > 0) shade(box, "spcpad", bl, bt, iw, pt, Math.round(pt));
|
|
940
|
+
if (pb > 0) shade(box, "spcpad", bl, bt + ih - pb, iw, pb, Math.round(pb));
|
|
941
|
+
if (pl > 0) shade(box, "spcpad", bl, bt + pt, pl, ih - pt - pb, Math.round(pl));
|
|
942
|
+
if (pr > 0) shade(box, "spcpad", bl + iw - pr, bt + pt, pr, ih - pt - pb, Math.round(pr));
|
|
943
|
+
// top/bottom margin (outside the box). Left/right skipped — margin:auto centering
|
|
944
|
+
// resolves to arbitrary px and would shade huge misleading bands (same as INV-9).
|
|
945
|
+
var mt = spx(cs.marginTop), mb = spx(cs.marginBottom);
|
|
946
|
+
if (mt > 0 && mt < 400) shade(box, "spcmar", 0, -mt, W, mt, "m " + Math.round(mt));
|
|
947
|
+
if (mb > 0 && mb < 400) shade(box, "spcmar", 0, H, W, mb, "m " + Math.round(mb));
|
|
948
|
+
// gaps between flex/grid children
|
|
949
|
+
drawGaps(box, el, cs, rect);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Escapes for BOTH text and attribute positions. Quotes matter: several templates below
|
|
953
|
+
// drop these values inside class="…" / title="…" / style="…", where a bare " breaks out
|
|
954
|
+
// and adds attributes. In a text position " renders as " so nothing changes visually.
|
|
955
|
+
function escHtml(s) {
|
|
956
|
+
return String(s == null ? "" : s)
|
|
957
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
958
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
959
|
+
}
|
|
960
|
+
// A CSS class fragment is a controlled vocabulary (base / component / pattern), and it
|
|
961
|
+
// is built from graph.js, which is space-published content. Anything else is dropped
|
|
962
|
+
// rather than escaped: a class name is not a place to render arbitrary text.
|
|
963
|
+
function slugClass(s) { return /^[a-z0-9-]+$/.test(String(s)) ? String(s) : ""; }
|
|
964
|
+
// Library page URL for a family/layer (base/, components/, patterns/).
|
|
965
|
+
function libUrl(info) { return info.url || null; }
|
|
966
|
+
|
|
967
|
+
/* ---------- the recursive import-chain panel ---------- */
|
|
968
|
+
// Render one token row with its LIVE resolved value (getComputedStyle from the
|
|
969
|
+
// element, so tenant-themed vars show their real value here) + the static raw +
|
|
970
|
+
// the alias chain from the graph.
|
|
971
|
+
function tokenRow(name, scopeEl) {
|
|
972
|
+
var t = GRAPH.tokens[name] || {};
|
|
973
|
+
var live = "";
|
|
974
|
+
try { live = getComputedStyle(scopeEl).getPropertyValue(name).trim(); } catch (e) {}
|
|
975
|
+
var raw = t.raw || t.value || "";
|
|
976
|
+
var col = /^(#|rgb|hsl)/i.test(live) ? live : (/^(#|rgb|hsl|color-mix)/i.test(raw) ? raw : "");
|
|
977
|
+
var chain = (t.chain && t.chain.length > 1) ? t.chain.join(" → ") + " → " + raw : "";
|
|
978
|
+
var row = document.createElement("div");
|
|
979
|
+
row.className = "tokrow";
|
|
980
|
+
row.innerHTML =
|
|
981
|
+
(col ? '<span class="sw" style="background:' + escHtml(col) + '"></span>' : '<span class="sw" style="background:#f3f4f7"></span>') +
|
|
982
|
+
'<span class="tk"><code></code><span class="res"></span></span>';
|
|
983
|
+
row.querySelector("code").textContent = name;
|
|
984
|
+
var res = row.querySelector(".res");
|
|
985
|
+
res.innerHTML = (live ? 'live <b>' + escHtml(live) + '</b>' : (raw ? '<b>' + escHtml(raw) + '</b>' : "")) +
|
|
986
|
+
(chain ? '<br>' + escHtml(chain) : "");
|
|
987
|
+
return row;
|
|
988
|
+
}
|
|
989
|
+
function chainRow(label, sublabel, onClick) {
|
|
990
|
+
var b = document.createElement("button");
|
|
991
|
+
b.type = "button"; b.className = "chainrow";
|
|
992
|
+
b.innerHTML = '<span class="cn"></span><span class="arr">›</span>';
|
|
993
|
+
b.querySelector(".cn").innerHTML = '<strong>' + escHtml(label) + '</strong>' + (sublabel ? ' <code>' + escHtml(sublabel) + '</code>' : "");
|
|
994
|
+
b.addEventListener("click", function (e) { e.stopPropagation(); onClick(); });
|
|
995
|
+
return b;
|
|
996
|
+
}
|
|
997
|
+
// Descendant elements of `el` classified at a given rank (deduped to outermost).
|
|
998
|
+
function descendantsOfRank(el, rank) {
|
|
999
|
+
var found = [], all = el.querySelectorAll("[class]");
|
|
1000
|
+
for (var i = 0; i < all.length; i++) {
|
|
1001
|
+
if (host.contains(all[i])) continue;
|
|
1002
|
+
var c = classifyEl(all[i]);
|
|
1003
|
+
if (c && c.rank === rank && all[i] !== el) { c.el = all[i]; found.push(c); }
|
|
1004
|
+
}
|
|
1005
|
+
return found.filter(function (a) {
|
|
1006
|
+
return !found.some(function (b) { return b.el !== a.el && b.el.contains(a.el); });
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
function openChain(family, el) {
|
|
1010
|
+
var info = GRAPH.classes[family]; if (!info) return;
|
|
1011
|
+
var layer = normLayer(info.layer);
|
|
1012
|
+
chainEl.classList.remove("hidden");
|
|
1013
|
+
chainEl.textContent = "";
|
|
1014
|
+
// header
|
|
1015
|
+
var h = document.createElement("div"); h.className = "chainp__h";
|
|
1016
|
+
h.innerHTML = '<span class="ttl"><span class="nm"></span><span class="chip l-' + slugClass(layer) + '">' + escHtml(layer) + '</span></span>' +
|
|
1017
|
+
'<button class="chainp__x" title="Close">×</button>';
|
|
1018
|
+
h.querySelector(".nm").textContent = info.label || family;
|
|
1019
|
+
h.querySelector(".chainp__x").addEventListener("click", closeChain);
|
|
1020
|
+
chainEl.appendChild(h);
|
|
1021
|
+
// jump to library
|
|
1022
|
+
var url = libUrl(info);
|
|
1023
|
+
if (url) {
|
|
1024
|
+
var jump = document.createElement("a");
|
|
1025
|
+
jump.className = "chainp__jump"; jump.href = url; jump.target = "_blank"; jump.rel = "noopener";
|
|
1026
|
+
jump.textContent = "Open " + layer + " in library ↗";
|
|
1027
|
+
chainEl.appendChild(jump);
|
|
1028
|
+
}
|
|
1029
|
+
var addSec = function (title) {
|
|
1030
|
+
var s = document.createElement("div"); s.className = "chainp__sec";
|
|
1031
|
+
s.innerHTML = '<h5>' + escHtml(title) + '</h5>';
|
|
1032
|
+
chainEl.appendChild(s); return s;
|
|
1033
|
+
};
|
|
1034
|
+
// pattern → the components inside it
|
|
1035
|
+
if (layer === "pattern") {
|
|
1036
|
+
var comps = descendantsOfRank(el, 2);
|
|
1037
|
+
var cs = addSec("Composes " + comps.length + " component" + (comps.length === 1 ? "" : "s"));
|
|
1038
|
+
if (!comps.length) cs.innerHTML += '<div class="chainp__empty">No nested canonical components detected here.</div>';
|
|
1039
|
+
comps.forEach(function (c) {
|
|
1040
|
+
cs.appendChild(chainRow(c.info.label, c.family, function () { openChain(c.family, c.el); }));
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
// component/pattern → base atoms inside it
|
|
1044
|
+
if (layer !== "base") {
|
|
1045
|
+
var bases = descendantsOfRank(el, 1);
|
|
1046
|
+
var bs = addSec("Built from " + bases.length + " base atom" + (bases.length === 1 ? "" : "s"));
|
|
1047
|
+
if (!bases.length) bs.innerHTML += '<div class="chainp__empty">No nested base atoms detected (it may draw straight from tokens).</div>';
|
|
1048
|
+
bases.forEach(function (b) {
|
|
1049
|
+
bs.appendChild(chainRow(b.info.label, b.family, function () { openChain(b.family, b.el); }));
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
// tokens this family drinks (always shown for base; for composites shown when
|
|
1053
|
+
// drilled to "+ Tokens", else summarised with a reveal).
|
|
1054
|
+
var toks = info.tokens || [];
|
|
1055
|
+
var ts = addSec("Drinks from " + toks.length + " token" + (toks.length === 1 ? "" : "s"));
|
|
1056
|
+
var showTokens = layer === "base" || state.drill >= 2;
|
|
1057
|
+
if (!toks.length) {
|
|
1058
|
+
ts.innerHTML += '<div class="chainp__empty">No direct tokens — it inherits from its base atoms.</div>';
|
|
1059
|
+
} else if (showTokens) {
|
|
1060
|
+
toks.forEach(function (name) { ts.appendChild(tokenRow(name, el)); });
|
|
1061
|
+
} else {
|
|
1062
|
+
var reveal = document.createElement("button");
|
|
1063
|
+
reveal.type = "button"; reveal.className = "chainrow";
|
|
1064
|
+
reveal.innerHTML = '<span class="cn">Show ' + toks.length + ' token value' + (toks.length === 1 ? "" : "s") + ' (live)</span><span class="arr">+</span>';
|
|
1065
|
+
reveal.addEventListener("click", function (e) {
|
|
1066
|
+
e.stopPropagation(); reveal.remove();
|
|
1067
|
+
toks.forEach(function (name) { ts.appendChild(tokenRow(name, el)); });
|
|
1068
|
+
});
|
|
1069
|
+
ts.appendChild(reveal);
|
|
1070
|
+
}
|
|
1071
|
+
chainOpenFor = { family: family, el: el };
|
|
1072
|
+
}
|
|
1073
|
+
var chainOpenFor = null;
|
|
1074
|
+
function closeChain() { chainEl.classList.add("hidden"); chainEl.textContent = ""; chainOpenFor = null; }
|
|
1075
|
+
|
|
1076
|
+
function reposition() {
|
|
1077
|
+
var pins = pinsEl.children, items = pinThreads().filter(function (t) { return pinXY(t); });
|
|
1078
|
+
for (var i = 0; i < pins.length && i < items.length; i++) {
|
|
1079
|
+
var xy = pinXY(items[i]);
|
|
1080
|
+
if (xy) { pins[i].style.left = xy.x + "px"; pins[i].style.top = xy.y + "px"; }
|
|
1081
|
+
}
|
|
1082
|
+
if (openCardAnchor) positionCard(openCardAnchor);
|
|
1083
|
+
if (tipEl.classList.contains("show")) hideTip(true); // anchor moved → drop bubble
|
|
1084
|
+
if (previewEl.classList.contains("show")) dropPreview();
|
|
1085
|
+
positionLinks();
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/* ---------- pin dragging ---------- */
|
|
1089
|
+
|
|
1090
|
+
function attachPinDrag(btn, id) {
|
|
1091
|
+
var sx, sy, moved, dragging, canDrag;
|
|
1092
|
+
btn.addEventListener("pointerdown", function (e) {
|
|
1093
|
+
// Draggable only in review mode; with review off (delivery mode) a pin is
|
|
1094
|
+
// read-only: tap toggles its note. Its own press never reaches the placing
|
|
1095
|
+
// listeners — they ignore anything inside our shadow root.
|
|
1096
|
+
e.preventDefault();
|
|
1097
|
+
e.stopPropagation();
|
|
1098
|
+
dropPreview();
|
|
1099
|
+
sx = e.clientX; sy = e.clientY; moved = false; dragging = true;
|
|
1100
|
+
canDrag = state.active;
|
|
1101
|
+
btn.setPointerCapture(e.pointerId);
|
|
1102
|
+
if (canDrag) btn.classList.add("dragging");
|
|
1103
|
+
});
|
|
1104
|
+
btn.addEventListener("pointermove", function (e) {
|
|
1105
|
+
if (!dragging || !canDrag) return;
|
|
1106
|
+
if (!moved && Math.abs(e.clientX - sx) + Math.abs(e.clientY - sy) < 4) return;
|
|
1107
|
+
moved = true;
|
|
1108
|
+
btn.style.left = e.clientX + "px"; btn.style.top = e.clientY + "px";
|
|
1109
|
+
});
|
|
1110
|
+
btn.addEventListener("pointerup", function (e) {
|
|
1111
|
+
if (!dragging) return;
|
|
1112
|
+
dragging = false; btn.classList.remove("dragging");
|
|
1113
|
+
try { btn.releasePointerCapture(e.pointerId); } catch (err) {}
|
|
1114
|
+
if (!moved) {
|
|
1115
|
+
if (state.active) openThread(id);
|
|
1116
|
+
else toggleTip(btn, id); // delivery mode: tap a cat to pin/unpin its note
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
var a = anchorAt(e.clientX, e.clientY);
|
|
1120
|
+
mutate({ op: "move", id: id, sel: a.sel, fx: a.fx, fy: a.fy, px: a.px, py: a.py, cwx: a.cwx, cwy: a.cwy, view: a.view });
|
|
1121
|
+
toast("Comment moved");
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/* ---------- annotation note bubble (delivery mode hover/tap) ---------- */
|
|
1126
|
+
|
|
1127
|
+
var tipSticky = null;
|
|
1128
|
+
function showTip(btn, id) {
|
|
1129
|
+
var t = find(id); if (!t) return;
|
|
1130
|
+
tipEl.textContent = (t.messages[0] && t.messages[0].body) || "";
|
|
1131
|
+
tipEl.classList.add("show"); // show first so offsetWidth/Height are measurable
|
|
1132
|
+
var r = btn.getBoundingClientRect();
|
|
1133
|
+
var vw = window.innerWidth, vh = window.innerHeight, m = 8;
|
|
1134
|
+
var w = tipEl.offsetWidth, h = tipEl.offsetHeight;
|
|
1135
|
+
var pinCx = r.left + r.width / 2, half = w / 2;
|
|
1136
|
+
// Clamp the (centre-anchored) bubble so neither edge leaves the viewport.
|
|
1137
|
+
var cx = pinCx;
|
|
1138
|
+
if (w + 2 * m <= vw) cx = Math.max(m + half, Math.min(pinCx, vw - m - half));
|
|
1139
|
+
// Sit above the pin, but flip below if that would clip the top edge.
|
|
1140
|
+
var below = (r.top - 10 - h) < m && (r.bottom + 10 + h) <= vh - m;
|
|
1141
|
+
tipEl.classList.toggle("below", below);
|
|
1142
|
+
tipEl.style.left = cx + "px";
|
|
1143
|
+
tipEl.style.top = (below ? r.bottom + 10 : r.top - 10) + "px";
|
|
1144
|
+
// Keep the tail pointing at the pin even after the body was nudged inward.
|
|
1145
|
+
var tailX = Math.max(12, Math.min(w - 12, pinCx - (cx - half)));
|
|
1146
|
+
tipEl.style.setProperty("--tail-x", tailX + "px");
|
|
1147
|
+
}
|
|
1148
|
+
function hideTip(force) { if (tipSticky && force !== true) return; tipSticky = null; tipEl.classList.remove("show"); }
|
|
1149
|
+
function toggleTip(btn, id) {
|
|
1150
|
+
if (tipSticky === id) { hideTip(true); return; }
|
|
1151
|
+
tipSticky = id; showTip(btn, id);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/* ---------- cards ---------- */
|
|
1155
|
+
|
|
1156
|
+
var openCardAnchor = null;
|
|
1157
|
+
function closeCard() { cardholder.textContent = ""; state.openId = null; openCardAnchor = null; renderPins(); if (state.active) renderList(); }
|
|
1158
|
+
function positionCard(at) {
|
|
1159
|
+
var card = cardholder.firstElementChild; if (!card) return;
|
|
1160
|
+
var w = card.offsetWidth, h = card.offsetHeight;
|
|
1161
|
+
var rightLimit = window.innerWidth - (sbCollapsed ? 12 : 312) - w;
|
|
1162
|
+
card.style.left = Math.min(Math.max(12, at.x + 16), Math.max(12, rightLimit)) + "px";
|
|
1163
|
+
card.style.top = Math.min(Math.max(12, at.y), window.innerHeight - h - 12) + "px";
|
|
1164
|
+
}
|
|
1165
|
+
function makeCard(at) { cardholder.textContent = ""; var c = document.createElement("div"); c.className = "card"; cardholder.appendChild(c); openCardAnchor = at; return c; }
|
|
1166
|
+
|
|
1167
|
+
// Render a message body, highlighting @mentions as accent tokens. Builds nodes
|
|
1168
|
+
// from split text (never innerHTML of user input) so it's injection-safe.
|
|
1169
|
+
function renderBody(el, text) {
|
|
1170
|
+
el.textContent = "";
|
|
1171
|
+
String(text == null ? "" : text).split(/(@[\w-]+)/g).forEach(function (p) {
|
|
1172
|
+
if (!p) return;
|
|
1173
|
+
if (/^@[\w-]+$/.test(p)) {
|
|
1174
|
+
var s = document.createElement("span"); s.className = "mention"; s.textContent = p; el.appendChild(s);
|
|
1175
|
+
} else el.appendChild(document.createTextNode(p));
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Wire one `.cfield` (a textarea + send button, optional name input) with the
|
|
1180
|
+
// shared compose behaviour: grey pill when idle, white box when focused/typed-in,
|
|
1181
|
+
// auto-grow, ⏎ to send / Shift+⏎ newline, send enabled only with text.
|
|
1182
|
+
function wireField(cfield, onSubmit) {
|
|
1183
|
+
var tx = cfield.querySelector(".tx"), send = cfield.querySelector(".send"), nm = cfield.querySelector(".nm");
|
|
1184
|
+
var focused = false;
|
|
1185
|
+
function grow() { tx.style.height = "auto"; tx.style.height = Math.min(tx.scrollHeight, 180) + "px"; }
|
|
1186
|
+
function look() {
|
|
1187
|
+
cfield.classList.toggle("idle", !focused && !tx.value.trim() && !(nm && nm.value.trim()));
|
|
1188
|
+
send.classList.toggle("on", !!tx.value.trim());
|
|
1189
|
+
}
|
|
1190
|
+
function fire() {
|
|
1191
|
+
var text = tx.value.trim(); if (!text) { tx.focus(); return; }
|
|
1192
|
+
onSubmit(text, nm ? nm.value.trim() : "");
|
|
1193
|
+
}
|
|
1194
|
+
tx.addEventListener("input", function () { grow(); look(); });
|
|
1195
|
+
tx.addEventListener("focus", function () { focused = true; look(); });
|
|
1196
|
+
tx.addEventListener("blur", function () { focused = false; look(); });
|
|
1197
|
+
if (nm) {
|
|
1198
|
+
nm.addEventListener("focus", function () { focused = true; look(); });
|
|
1199
|
+
nm.addEventListener("blur", function () { focused = false; look(); });
|
|
1200
|
+
}
|
|
1201
|
+
tx.addEventListener("keydown", function (e) {
|
|
1202
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); fire(); }
|
|
1203
|
+
});
|
|
1204
|
+
send.addEventListener("click", fire);
|
|
1205
|
+
grow(); look();
|
|
1206
|
+
return { focus: function () { (nm || tx).focus(); } };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function composeNew(loc) {
|
|
1210
|
+
dropPreview();
|
|
1211
|
+
state.openId = null; renderPins(); renderList();
|
|
1212
|
+
cardholder.textContent = "";
|
|
1213
|
+
var wrap = document.createElement("div");
|
|
1214
|
+
wrap.className = "compose";
|
|
1215
|
+
var needName = !getName();
|
|
1216
|
+
wrap.innerHTML = '<span class="cpin"></span>' +
|
|
1217
|
+
'<div class="cfield idle">' +
|
|
1218
|
+
(needName ? '<input class="nm" placeholder="Your name" />' : '') +
|
|
1219
|
+
'<textarea class="tx" rows="1" placeholder="Add a comment"></textarea>' +
|
|
1220
|
+
'<button class="send" title="Send">' + SVG.send + '</button>' +
|
|
1221
|
+
'</div>';
|
|
1222
|
+
cardholder.appendChild(wrap);
|
|
1223
|
+
openCardAnchor = { x: loc.px - window.scrollX, y: loc.py - window.scrollY };
|
|
1224
|
+
positionCard(openCardAnchor);
|
|
1225
|
+
var api = wireField(wrap.querySelector(".cfield"), function (text, name) {
|
|
1226
|
+
name = name || getName() || "Anonymous";
|
|
1227
|
+
if (wrap.querySelector(".nm")) setName(name);
|
|
1228
|
+
var thread = { id: uid(), sel: loc.sel, fx: loc.fx, fy: loc.fy, px: loc.px, py: loc.py,
|
|
1229
|
+
cwx: loc.cwx, cwy: loc.cwy,
|
|
1230
|
+
view: loc.view, screen: loc.screen, resolved: false, annotation: false,
|
|
1231
|
+
messages: [{ author: name, by: ME && ME.id, verified: !!ME, body: text, at: nowIso() }] };
|
|
1232
|
+
closeCard();
|
|
1233
|
+
mutate({ op: "add", thread: thread });
|
|
1234
|
+
toast("Comment added");
|
|
1235
|
+
});
|
|
1236
|
+
api.focus();
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function delThread(id) {
|
|
1240
|
+
if (!confirm("Delete this comment thread?")) return;
|
|
1241
|
+
deleted[id] = 1;
|
|
1242
|
+
mutate({ op: "delete", id: id }).then(closeCard);
|
|
1243
|
+
}
|
|
1244
|
+
function delMsg(id, index) {
|
|
1245
|
+
if (index === 0) { delThread(id); return; } // root message = whole thread
|
|
1246
|
+
if (!confirm("Delete this reply?")) return;
|
|
1247
|
+
mutate({ op: "delmsg", id: id, index: index }).then(function () { openThread(id); });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function openThread(id) {
|
|
1251
|
+
var t = find(id); if (!t) return;
|
|
1252
|
+
dropPreview();
|
|
1253
|
+
state.mode = "browse"; state.openId = id;
|
|
1254
|
+
var xy = pinXY(t) || { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
1255
|
+
render();
|
|
1256
|
+
var card = makeCard(xy);
|
|
1257
|
+
// Header: title + icon cluster — ⋯ delete · cat annotate · ✓ resolve · ✕ close.
|
|
1258
|
+
card.innerHTML = '<div class="thead"><strong>' + (t.annotation ? 'Annotation' : 'Comment') + '</strong>' +
|
|
1259
|
+
'<button class="ico del" title="Delete">' + SVG.dots + '</button>' +
|
|
1260
|
+
'<button class="ico cat' + (t.annotation ? ' on' : '') + '" title="' +
|
|
1261
|
+
(t.annotation ? 'Annotation — always-on, skipped on resolve. Click to make it a normal comment.' : 'Make this an annotation — always-on dev note, skipped when resolving comments.') +
|
|
1262
|
+
'"><img src="' + CAT + '" alt=""></button>' +
|
|
1263
|
+
'<button class="ico res' + (t.resolved ? ' done' : '') + '" title="' + (t.resolved ? 'Reopen' : 'Resolve') + '">' + SVG.check + '</button>' +
|
|
1264
|
+
'<button class="ico close" title="Close">' + SVG.close + '</button></div>' +
|
|
1265
|
+
'<div class="msgs"></div>' +
|
|
1266
|
+
'<div class="replybar"><div class="rav"></div><div class="cfield idle">' +
|
|
1267
|
+
'<textarea class="tx" rows="1" placeholder="Reply"></textarea>' +
|
|
1268
|
+
'<button class="send" title="Send">' + SVG.send + '</button></div></div>';
|
|
1269
|
+
var msgs = card.querySelector(".msgs");
|
|
1270
|
+
var rav = card.querySelector(".replybar .rav");
|
|
1271
|
+
if (ME) rav.appendChild(avatarEl(ME, 28)); else rav.remove();
|
|
1272
|
+
t.messages.forEach(function (m, i) {
|
|
1273
|
+
var d = document.createElement("div"); d.className = "msg";
|
|
1274
|
+
d.innerHTML = '<div class="mav"></div><div class="mbody">' +
|
|
1275
|
+
'<div class="mhead"><span class="who"></span><span class="when" data-iso=""></span>' +
|
|
1276
|
+
'<button class="mdel" title="Delete">' + SVG.dots + '</button></div>' +
|
|
1277
|
+
'<div class="body"></div></div>';
|
|
1278
|
+
var mp = personFor(m);
|
|
1279
|
+
var mav = d.querySelector(".mav");
|
|
1280
|
+
// Unlike .rav/.pav (a single, non-stacked instance), .mav sits in a column of
|
|
1281
|
+
// sibling messages — removing it on an anonymous row would collapse its flex
|
|
1282
|
+
// gap and shift that row's body to a different left edge than faced rows
|
|
1283
|
+
// above/below. Leave the (empty) wrapper in place; ".msg .mav{width:28px}"
|
|
1284
|
+
// reserves the same column width whether or not it holds a face.
|
|
1285
|
+
if (mp) mav.appendChild(avatarEl(mp, 28));
|
|
1286
|
+
d.querySelector(".who").textContent = m.author;
|
|
1287
|
+
var w = d.querySelector(".when"); w.textContent = fmt(m.at); w.setAttribute("data-iso", m.at || "");
|
|
1288
|
+
renderBody(d.querySelector(".body"), m.body);
|
|
1289
|
+
d.querySelector(".mdel").addEventListener("click", function () { delMsg(id, i); });
|
|
1290
|
+
msgs.appendChild(d);
|
|
1291
|
+
});
|
|
1292
|
+
positionCard(xy);
|
|
1293
|
+
wireField(card.querySelector(".replybar .cfield"), function (text) {
|
|
1294
|
+
mutate({ op: "reply", id: id, message: { author: getName() || "Anonymous",
|
|
1295
|
+
by: ME && ME.id, verified: !!ME, body: text, at: nowIso() } })
|
|
1296
|
+
.then(function () { openThread(id); });
|
|
1297
|
+
});
|
|
1298
|
+
card.querySelector(".res").addEventListener("click", function () {
|
|
1299
|
+
mutate({ op: "resolve", id: id, resolved: !t.resolved }).then(closeCard);
|
|
1300
|
+
});
|
|
1301
|
+
card.querySelector(".close").addEventListener("click", closeCard);
|
|
1302
|
+
card.querySelector(".cat").addEventListener("click", function () {
|
|
1303
|
+
var willBe = !t.annotation;
|
|
1304
|
+
mutate({ op: "annotate", id: id, annotation: willBe }).then(function () {
|
|
1305
|
+
toast(willBe ? "Now an annotation · always-on for devs" : "Back to a comment");
|
|
1306
|
+
openThread(id);
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
card.querySelector(".del").addEventListener("click", function () { delThread(id); });
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/* ---------- hover preview: the pin grows into the card, and back ----------
|
|
1313
|
+
* The pin is not a launcher for a card beside it — it IS the card, collapsed. On
|
|
1314
|
+
* hover a white bubble exactly the pin's size and place opens out into the full
|
|
1315
|
+
* preview around a CLONE of that pin, and the real pin hides underneath it, so the
|
|
1316
|
+
* chip never moves by a pixel and there are never two of it. Leaving shrinks the
|
|
1317
|
+
* bubble back down onto the pin before it disappears.
|
|
1318
|
+
*
|
|
1319
|
+
* The card is a live surface, not a tooltip: it takes pointer events, so you can
|
|
1320
|
+
* move onto it (hovering it keeps it up), click it to open the thread, and drag it
|
|
1321
|
+
* to move the comment — the pin it covers can no longer receive any of those.
|
|
1322
|
+
* `pointer-events` is also what keeps the morph stable: hiding the pin under a
|
|
1323
|
+
* pointer-transparent card would fire the pin's own mouseleave and hide the card
|
|
1324
|
+
* again, one frame after showing it (the bug that made this feature invisible for
|
|
1325
|
+
* every pin that had a face). */
|
|
1326
|
+
|
|
1327
|
+
var PREVIEW_W = 280; // the card's grown width; the collapsed size comes from the pin
|
|
1328
|
+
var PAD_X = 15, PAD_Y = 13, LEAD_GAP = 10; // must track .preview's padding + gap
|
|
1329
|
+
var previewTimer = null, previewHideTimer = null, previewEndTimer = null;
|
|
1330
|
+
var previewId = null, previewBtn = null, previewSmall = null;
|
|
1331
|
+
|
|
1332
|
+
function queuePreview(btn, id) {
|
|
1333
|
+
clearTimeout(previewHideTimer);
|
|
1334
|
+
if (previewId === id && previewEl.classList.contains("show")) return;
|
|
1335
|
+
clearTimeout(previewTimer);
|
|
1336
|
+
previewTimer = setTimeout(function () { showPreview(btn, id); }, 130);
|
|
1337
|
+
}
|
|
1338
|
+
// A grace period on leave: the cursor crosses a seam between pin and card, and a
|
|
1339
|
+
// card that vanished on the way over could never be reached.
|
|
1340
|
+
function queueHidePreview() {
|
|
1341
|
+
clearTimeout(previewTimer);
|
|
1342
|
+
clearTimeout(previewHideTimer);
|
|
1343
|
+
previewHideTimer = setTimeout(hidePreview, 110);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function showPreview(btn, id) {
|
|
1347
|
+
var t = find(id); if (!t) return;
|
|
1348
|
+
var m = t.messages[0] || {};
|
|
1349
|
+
clearTimeout(previewEndTimer);
|
|
1350
|
+
// Moving pin → pin: give the pin we were covering back before we cover another,
|
|
1351
|
+
// or it stays invisible for the rest of the session.
|
|
1352
|
+
var prev = pinsEl.querySelector(".pin.under");
|
|
1353
|
+
if (prev) prev.classList.remove("under");
|
|
1354
|
+
previewId = id; previewBtn = btn;
|
|
1355
|
+
|
|
1356
|
+
// The lead chip is the pin itself, cloned: whatever the pin wears — a face, a
|
|
1357
|
+
// number, the resolved ring — the card wears the same, with no second code path
|
|
1358
|
+
// to drift. .pin.lead unsets the fixed positioning it carries as a pin.
|
|
1359
|
+
var chip = btn.cloneNode(true);
|
|
1360
|
+
chip.removeAttribute("style");
|
|
1361
|
+
chip.classList.remove("under", "active", "dragging");
|
|
1362
|
+
chip.classList.add("lead");
|
|
1363
|
+
chip.removeAttribute("title");
|
|
1364
|
+
previewEl.className = "preview";
|
|
1365
|
+
previewEl.innerHTML = '<div class="pbody">' +
|
|
1366
|
+
'<div class="phead"><span class="pname"></span><span class="when" data-iso=""></span></div>' +
|
|
1367
|
+
'<div class="body"></div></div>';
|
|
1368
|
+
previewEl.insertBefore(chip, previewEl.firstChild);
|
|
1369
|
+
previewEl.querySelector(".pname").textContent = m.author || "";
|
|
1370
|
+
var w = previewEl.querySelector(".when"); w.textContent = fmt(m.at); w.setAttribute("data-iso", m.at || "");
|
|
1371
|
+
renderBody(previewEl.querySelector(".body"), m.body);
|
|
1372
|
+
|
|
1373
|
+
// Measure at full size while still invisible (visibility:hidden still lays out).
|
|
1374
|
+
var r = btn.getBoundingClientRect(), vw = window.innerWidth, vh = window.innerHeight, m2 = 8;
|
|
1375
|
+
var leadW = r.width, leadH = r.height;
|
|
1376
|
+
previewEl.style.transition = "none";
|
|
1377
|
+
previewEl.style.borderRadius = "";
|
|
1378
|
+
previewEl.style.width = PREVIEW_W + "px";
|
|
1379
|
+
previewEl.style.height = "auto";
|
|
1380
|
+
previewEl.querySelector(".pbody").style.width = (PREVIEW_W - PAD_X * 2 - leadW - LEAD_GAP) + "px";
|
|
1381
|
+
var pw = previewEl.offsetWidth, ph = previewEl.offsetHeight;
|
|
1382
|
+
|
|
1383
|
+
// Grown geometry: the lead chip's centre lands exactly on the pin's centre, which
|
|
1384
|
+
// puts the card's leading edge PAD_X outside the pin's edge. (r already includes
|
|
1385
|
+
// the pin's translate, so its centre is r.left + r.width/2.)
|
|
1386
|
+
var pinCx = r.left + r.width / 2, pinCy = r.top + r.height / 2;
|
|
1387
|
+
var wantLeft = pinCx - r.width / 2 - PAD_X; // grows rightward
|
|
1388
|
+
var wantTop = pinCy - (PAD_Y + leadH / 2);
|
|
1389
|
+
var useLeft = wantLeft + pw > vw - m2; // no room right → flip
|
|
1390
|
+
if (useLeft) { wantLeft = pinCx + r.width / 2 + PAD_X - pw; previewEl.classList.add("left"); }
|
|
1391
|
+
var left = Math.max(m2, Math.min(wantLeft, vw - pw - m2));
|
|
1392
|
+
var top = Math.max(m2, Math.min(wantTop, vh - ph - m2));
|
|
1393
|
+
|
|
1394
|
+
// A card the viewport had to shove can't keep its chip on the pin, and a morph
|
|
1395
|
+
// that lands off its own origin is worse than none: fall back to a plain card
|
|
1396
|
+
// beside a pin that stays visible.
|
|
1397
|
+
var morph = Math.abs(left - wantLeft) <= 1 && Math.abs(top - wantTop) <= 1;
|
|
1398
|
+
var smallW = leadW + PAD_X * 2, smallH = leadH + PAD_Y * 2;
|
|
1399
|
+
|
|
1400
|
+
// Anchor the flipped card by its RIGHT edge so growing widens it leftward, away
|
|
1401
|
+
// from the pin, instead of dragging the chip along with the left edge.
|
|
1402
|
+
if (useLeft) { previewEl.style.left = "auto"; previewEl.style.right = (vw - left - pw) + "px"; }
|
|
1403
|
+
else { previewEl.style.right = "auto"; previewEl.style.left = left + "px"; }
|
|
1404
|
+
previewEl.style.top = (morph ? top : Math.max(m2, Math.min(r.top - 6, vh - ph - m2))) + "px";
|
|
1405
|
+
|
|
1406
|
+
if (morph) {
|
|
1407
|
+
previewSmall = { w: smallW, h: smallH };
|
|
1408
|
+
btn.classList.add("under");
|
|
1409
|
+
previewEl.style.width = smallW + "px";
|
|
1410
|
+
previewEl.style.height = smallH + "px";
|
|
1411
|
+
previewEl.style.borderRadius = "999px";
|
|
1412
|
+
previewEl.classList.add("show");
|
|
1413
|
+
void previewEl.offsetWidth; // commit the collapsed state, then grow
|
|
1414
|
+
previewEl.style.transition = "";
|
|
1415
|
+
previewEl.style.width = pw + "px";
|
|
1416
|
+
previewEl.style.height = ph + "px";
|
|
1417
|
+
previewEl.style.borderRadius = "";
|
|
1418
|
+
previewEl.classList.add("grown");
|
|
1419
|
+
} else {
|
|
1420
|
+
previewSmall = null;
|
|
1421
|
+
previewEl.style.width = pw + "px";
|
|
1422
|
+
previewEl.style.height = ph + "px";
|
|
1423
|
+
previewEl.classList.add("show");
|
|
1424
|
+
void previewEl.offsetWidth;
|
|
1425
|
+
previewEl.style.transition = "";
|
|
1426
|
+
previewEl.classList.add("grown");
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function hidePreview() {
|
|
1431
|
+
clearTimeout(previewTimer);
|
|
1432
|
+
clearTimeout(previewHideTimer);
|
|
1433
|
+
clearTimeout(previewEndTimer);
|
|
1434
|
+
previewId = null; previewBtn = null;
|
|
1435
|
+
var u = pinsEl.querySelector(".pin.under");
|
|
1436
|
+
if (u) u.classList.remove("under"); // the shrinking bubble covers it until it lands
|
|
1437
|
+
if (!previewEl.classList.contains("show")) { previewEl.classList.remove("grown"); return; }
|
|
1438
|
+
if (!previewSmall) { previewEl.classList.remove("show", "grown"); return; }
|
|
1439
|
+
previewEl.classList.remove("grown");
|
|
1440
|
+
previewEl.style.width = previewSmall.w + "px";
|
|
1441
|
+
previewEl.style.height = previewSmall.h + "px";
|
|
1442
|
+
previewEl.style.borderRadius = "999px";
|
|
1443
|
+
previewEndTimer = setTimeout(function () { previewEl.classList.remove("show"); }, 190);
|
|
1444
|
+
}
|
|
1445
|
+
// Kill it outright (no shrink) — for a state change that makes the card meaningless,
|
|
1446
|
+
// like the thread opening or the pin being dragged out from under it.
|
|
1447
|
+
function dropPreview() {
|
|
1448
|
+
clearTimeout(previewTimer);
|
|
1449
|
+
clearTimeout(previewHideTimer);
|
|
1450
|
+
clearTimeout(previewEndTimer);
|
|
1451
|
+
previewId = null; previewBtn = null; previewSmall = null;
|
|
1452
|
+
previewEl.classList.remove("show", "grown");
|
|
1453
|
+
var u = pinsEl.querySelector(".pin.under");
|
|
1454
|
+
if (u) u.classList.remove("under");
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
previewEl.addEventListener("mouseenter", function () { clearTimeout(previewHideTimer); });
|
|
1458
|
+
previewEl.addEventListener("mouseleave", queueHidePreview);
|
|
1459
|
+
|
|
1460
|
+
// The card stands in for the pin it covers, so it carries the pin's gestures too:
|
|
1461
|
+
// click opens the thread, drag moves the comment (review mode only, same as a pin).
|
|
1462
|
+
var cardDrag = null;
|
|
1463
|
+
previewEl.addEventListener("pointerdown", function (e) {
|
|
1464
|
+
if (!previewBtn || !previewId) return;
|
|
1465
|
+
e.preventDefault(); e.stopPropagation();
|
|
1466
|
+
cardDrag = { sx: e.clientX, sy: e.clientY, moved: false, btn: previewBtn, id: previewId,
|
|
1467
|
+
px: parseFloat(previewBtn.style.left) || 0, py: parseFloat(previewBtn.style.top) || 0 };
|
|
1468
|
+
try { previewEl.setPointerCapture(e.pointerId); } catch (err) {}
|
|
1469
|
+
});
|
|
1470
|
+
previewEl.addEventListener("pointermove", function (e) {
|
|
1471
|
+
if (!cardDrag || !state.active) return;
|
|
1472
|
+
if (!cardDrag.moved && Math.abs(e.clientX - cardDrag.sx) + Math.abs(e.clientY - cardDrag.sy) < 4) return;
|
|
1473
|
+
if (!cardDrag.moved) { cardDrag.moved = true; dropPreview(); cardDrag.btn.classList.add("dragging"); }
|
|
1474
|
+
cardDrag.btn.style.left = (cardDrag.px + e.clientX - cardDrag.sx) + "px";
|
|
1475
|
+
cardDrag.btn.style.top = (cardDrag.py + e.clientY - cardDrag.sy) + "px";
|
|
1476
|
+
});
|
|
1477
|
+
previewEl.addEventListener("pointerup", function (e) {
|
|
1478
|
+
if (!cardDrag) return;
|
|
1479
|
+
var d = cardDrag; cardDrag = null;
|
|
1480
|
+
try { previewEl.releasePointerCapture(e.pointerId); } catch (err) {}
|
|
1481
|
+
d.btn.classList.remove("dragging");
|
|
1482
|
+
if (!d.moved) { dropPreview(); openThread(d.id); return; }
|
|
1483
|
+
// The pin moved with the cursor, so its new anchor is where the pin now sits —
|
|
1484
|
+
// not the cursor itself (the card was grabbed somewhere off the chip).
|
|
1485
|
+
var a = anchorAt(d.px + e.clientX - d.sx, d.py + e.clientY - d.sy);
|
|
1486
|
+
mutate({ op: "move", id: d.id, sel: a.sel, fx: a.fx, fy: a.fy, px: a.px, py: a.py, cwx: a.cwx, cwy: a.cwy, view: a.view });
|
|
1487
|
+
toast("Comment moved");
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
// Open a thread; if it lives on another screen, navigate there first.
|
|
1491
|
+
function openOrNavigate(id) {
|
|
1492
|
+
var t = find(id); if (!t) return;
|
|
1493
|
+
if (resolvesHere(t)) {
|
|
1494
|
+
var el = anchorOf(t);
|
|
1495
|
+
if (el && el.scrollIntoView) el.scrollIntoView({ block: "center", inline: "center" });
|
|
1496
|
+
setTimeout(function () { openThread(id); }, 60);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
if (!onThisView(t) && t.view) {
|
|
1500
|
+
try { sessionStorage.setItem(SS_PENDING, id); } catch (e) {}
|
|
1501
|
+
var dest = new URL(t.view, location.href).href;
|
|
1502
|
+
if (dest === location.href) { tryOpenPending(); return; }
|
|
1503
|
+
location.href = dest; // reload or hashchange → tryOpenPending runs on arrival
|
|
1504
|
+
setTimeout(tryOpenPending, 80); // same-document (hash) case
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function tryOpenPending() {
|
|
1509
|
+
var id; try { id = sessionStorage.getItem(SS_PENDING); } catch (e) {}
|
|
1510
|
+
if (!id) return;
|
|
1511
|
+
var attempts = 0;
|
|
1512
|
+
(function poll() {
|
|
1513
|
+
var t = find(id);
|
|
1514
|
+
if (t && resolvesHere(t)) {
|
|
1515
|
+
try { sessionStorage.removeItem(SS_PENDING); } catch (e) {}
|
|
1516
|
+
if (!state.active) setActive(true);
|
|
1517
|
+
var el = anchorOf(t); if (el && el.scrollIntoView) el.scrollIntoView({ block: "center" });
|
|
1518
|
+
setTimeout(function () { openThread(id); }, 60);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (attempts++ < 20) setTimeout(poll, 100);
|
|
1522
|
+
else { try { sessionStorage.removeItem(SS_PENDING); } catch (e) {} }
|
|
1523
|
+
})();
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Relative time: "Just now" → "1 minute ago" → "N min. ago" → hours
|
|
1527
|
+
// → date. Open cards carry data-iso on each .when so the ticker can re-age them.
|
|
1528
|
+
function fmt(iso) {
|
|
1529
|
+
try {
|
|
1530
|
+
var s = (Date.now() - new Date(iso).getTime()) / 1000;
|
|
1531
|
+
if (isNaN(s)) return "";
|
|
1532
|
+
if (s < 45) return "Just now";
|
|
1533
|
+
if (s < 90) return "1 minute ago";
|
|
1534
|
+
if (s < 3600) return Math.round(s / 60) + " min. ago";
|
|
1535
|
+
if (s < 5400) return "1 hour ago";
|
|
1536
|
+
if (s < 86400) return Math.round(s / 3600) + " hours ago";
|
|
1537
|
+
if (s < 172800) return "Yesterday";
|
|
1538
|
+
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
1539
|
+
} catch (e) { return ""; }
|
|
1540
|
+
}
|
|
1541
|
+
// Re-age every visible relative timestamp (open card + preview) so "Just now"
|
|
1542
|
+
// becomes "1 minute ago" without needing a re-render.
|
|
1543
|
+
setInterval(function () {
|
|
1544
|
+
var els = root.querySelectorAll(".when[data-iso]");
|
|
1545
|
+
for (var i = 0; i < els.length; i++) {
|
|
1546
|
+
var iso = els[i].getAttribute("data-iso");
|
|
1547
|
+
if (iso) els[i].textContent = fmt(iso);
|
|
1548
|
+
}
|
|
1549
|
+
}, 30000);
|
|
1550
|
+
|
|
1551
|
+
/* ---------- modes ---------- */
|
|
1552
|
+
|
|
1553
|
+
function setActive(on) {
|
|
1554
|
+
state.active = on;
|
|
1555
|
+
try { sessionStorage.setItem(LS_ACTIVE, on ? "1" : "0"); } catch (e) {}
|
|
1556
|
+
hideTip(true);
|
|
1557
|
+
if (on) { state.mode = "add"; } else { closeCard(); closeChain(); }
|
|
1558
|
+
render();
|
|
1559
|
+
if (on) toast("Review on · click to comment · Esc to exit · ↑/↓ for layers (+ Tokens shows spacing)");
|
|
1560
|
+
}
|
|
1561
|
+
// Set the layer drilldown (Components → +Base → +Tokens) and re-detect. Wraps, so it
|
|
1562
|
+
// works the same from the click (cycle forward) and the ↑/↓ keys (step either way).
|
|
1563
|
+
function setDrill(n) {
|
|
1564
|
+
var len = DRILL.length;
|
|
1565
|
+
state.drill = ((n % len) + len) % len;
|
|
1566
|
+
if (chainOpenFor) openChain(chainOpenFor.family, chainOpenFor.el); // refresh open panel
|
|
1567
|
+
renderLinks();
|
|
1568
|
+
toast("Layers: " + DRILL[state.drill]);
|
|
1569
|
+
}
|
|
1570
|
+
drillEl.addEventListener("click", function () { setDrill(state.drill + 1); });
|
|
1571
|
+
function setMode(m) { state.mode = m; if (m === "add") closeCard(); render(); }
|
|
1572
|
+
|
|
1573
|
+
/* ---------- placing mode: no catcher, no tint ----------
|
|
1574
|
+
The only model that works over an infinite canvas: nothing is laid
|
|
1575
|
+
over the page. A full-screen catcher had to swallow every gesture to catch the click,
|
|
1576
|
+
which meant the board underneath could not be panned or zoomed while you commented —
|
|
1577
|
+
and it tinted the thing you were trying to look at.
|
|
1578
|
+
Instead: the cursor says you're placing, wheel/pinch/keyboard stay completely native
|
|
1579
|
+
(so scroll, trackpad pan and zoom work exactly as they always do), and a MOUSE press is
|
|
1580
|
+
swallowed in the capture phase so it never reaches what's underneath — no sticky gets
|
|
1581
|
+
selected or dragged, no button lights up. A press that doesn't travel is a click, and
|
|
1582
|
+
that places the comment. Touch is left alone: on a touch device the same press IS the
|
|
1583
|
+
pan gesture, so a tap places the pin and a drag still scrolls. */
|
|
1584
|
+
var PLACE_SLOP = 5; // px a press may travel and still count as a click
|
|
1585
|
+
var pressAt = null;
|
|
1586
|
+
|
|
1587
|
+
function placing() { return state.active && state.mode === "add"; }
|
|
1588
|
+
// events from inside our own shadow DOM retarget to the host — pins, sidebar, card.
|
|
1589
|
+
// e.button is 0 for a left press and for every touch; anything else (right, middle) is
|
|
1590
|
+
// someone else's gesture — the context menu, a middle-drag pan — and passes through.
|
|
1591
|
+
// [data-gv-chrome] is the page's OWN controls (the canvas toolbar, its zoom and timer):
|
|
1592
|
+
// commenting freezes the content you're commenting on, never the instruments around it —
|
|
1593
|
+
// you have to be able to zoom, change tool, and leave the mode by clicking.
|
|
1594
|
+
function ours(e) {
|
|
1595
|
+
if (e.target === host || host.contains(e.target) || e.button) return true;
|
|
1596
|
+
return !!(e.target && e.target.closest && e.target.closest("[data-gv-chrome]"));
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// The comment cursor, forced onto the page: `*` beats the cursor rules the page sets on
|
|
1600
|
+
// its own elements (a button's pointer, the canvas's grab), and the overlay's own chrome
|
|
1601
|
+
// is untouched behind the shadow boundary.
|
|
1602
|
+
var cursorStyle = null;
|
|
1603
|
+
function placeCursor(on) {
|
|
1604
|
+
if (on === !!cursorStyle) return;
|
|
1605
|
+
if (!on) { cursorStyle.remove(); cursorStyle = null; return; }
|
|
1606
|
+
cursorStyle = document.createElement("style");
|
|
1607
|
+
cursorStyle.textContent = "*{cursor:url(\"/__review/comment-cursor.svg\") 4 23,crosshair!important}";
|
|
1608
|
+
document.head.appendChild(cursorStyle);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
window.addEventListener("pointerdown", function (e) {
|
|
1612
|
+
if (!placing() || ours(e)) return;
|
|
1613
|
+
pressAt = { x: e.clientX, y: e.clientY, id: e.pointerId };
|
|
1614
|
+
if (e.pointerType === "touch") return; // a touch press IS the pan gesture
|
|
1615
|
+
e.stopPropagation(); e.preventDefault();
|
|
1616
|
+
}, true);
|
|
1617
|
+
window.addEventListener("pointerup", function (e) {
|
|
1618
|
+
if (!placing() || ours(e)) return;
|
|
1619
|
+
var p = pressAt; pressAt = null;
|
|
1620
|
+
if (e.pointerType !== "touch") { e.stopPropagation(); e.preventDefault(); }
|
|
1621
|
+
if (!p || p.id !== e.pointerId) return; // pinch, or a press that began elsewhere
|
|
1622
|
+
if (Math.abs(e.clientX - p.x) > PLACE_SLOP || Math.abs(e.clientY - p.y) > PLACE_SLOP) return; // a drag, not a click
|
|
1623
|
+
composeNew(anchorAt(e.clientX, e.clientY));
|
|
1624
|
+
}, true);
|
|
1625
|
+
window.addEventListener("pointercancel", function () { pressAt = null; }, true);
|
|
1626
|
+
// the mouse events the page would otherwise still see (a click fires even when its
|
|
1627
|
+
// pointerdown was defaultPrevented, and dblclick/mousedown drive plenty of prototypes)
|
|
1628
|
+
["mousedown", "mouseup", "click", "dblclick"].forEach(function (t) {
|
|
1629
|
+
window.addEventListener(t, function (e) {
|
|
1630
|
+
if (!placing() || ours(e)) return;
|
|
1631
|
+
e.stopPropagation(); e.preventDefault();
|
|
1632
|
+
}, true);
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
$(".newc").addEventListener("click", function () { setMode("add"); });
|
|
1636
|
+
$(".collapse").addEventListener("click", function () { toggleSidebar(true); });
|
|
1637
|
+
tabEl.addEventListener("click", function () { toggleSidebar(false); });
|
|
1638
|
+
function toggleSidebar(collapse) {
|
|
1639
|
+
sbCollapsed = collapse;
|
|
1640
|
+
try { localStorage.setItem(LS_SB, collapse ? "1" : "0"); } catch (e) {}
|
|
1641
|
+
render();
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
var toastT;
|
|
1645
|
+
function toast(msg) {
|
|
1646
|
+
toastEl.textContent = msg; toastEl.classList.add("show");
|
|
1647
|
+
clearTimeout(toastT); toastT = setTimeout(function () { toastEl.classList.remove("show"); }, 1900);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function isTyping(el) {
|
|
1651
|
+
if (!el) return false;
|
|
1652
|
+
var tag = (el.tagName || "").toLowerCase();
|
|
1653
|
+
return tag === "input" || tag === "textarea" || el.isContentEditable;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
window.addEventListener("keydown", function (e) {
|
|
1657
|
+
// e.target is retargeted to the shadow host for events from inside our
|
|
1658
|
+
// shadow DOM, so check the real originating node via composedPath.
|
|
1659
|
+
var src = (e.composedPath && e.composedPath()[0]) || e.target;
|
|
1660
|
+
// The shortcut is plain Shift+C. Any OTHER modifier makes it a different chord that
|
|
1661
|
+
// belongs to someone else — ⌘⇧C / Ctrl+⇧C is a copy-as-image chord in canvases and in
|
|
1662
|
+
// most host apps — and this listener is on window in the CAPTURE phase with a
|
|
1663
|
+
// preventDefault(), so without these guards it swallowed those chords everywhere the
|
|
1664
|
+
// overlay is injected (every prototype page, including ones framed inside a canvas tile).
|
|
1665
|
+
if (e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey && (e.code === "KeyC" || e.key === "C" || e.key === "c") && !isTyping(src)) {
|
|
1666
|
+
e.preventDefault(); setActive(!state.active);
|
|
1667
|
+
} else if (e.key === "Escape" && state.active) {
|
|
1668
|
+
e.preventDefault(); setActive(false); // Esc exits comment mode outright
|
|
1669
|
+
} else if (state.active && !isTyping(src) && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
1670
|
+
// ↑/↓ steps the bottom-left Layers depth (down = deeper toward + Tokens).
|
|
1671
|
+
e.preventDefault(); setDrill(state.drill + (e.key === "ArrowDown" ? 1 : -1));
|
|
1672
|
+
}
|
|
1673
|
+
}, true);
|
|
1674
|
+
|
|
1675
|
+
/* ---------- lifecycle: orphan sweep + view changes ---------- */
|
|
1676
|
+
|
|
1677
|
+
// A comment is an orphan when we're on its own screen but its anchor is gone.
|
|
1678
|
+
// Confirm after a short delay so a transient re-render doesn't delete it.
|
|
1679
|
+
var orphanTimers = {};
|
|
1680
|
+
function isOrphan(t) { return onThisView(t) && t.view != null && !resolvesHere(t); }
|
|
1681
|
+
function orphanSweep() {
|
|
1682
|
+
if (!settled) return;
|
|
1683
|
+
state.threads.forEach(function (t) {
|
|
1684
|
+
if (deleted[t.id]) return;
|
|
1685
|
+
if (isAnno(t)) return; // annotations are delivery notes — never auto-delete
|
|
1686
|
+
if (isOrphan(t)) {
|
|
1687
|
+
if (!orphanTimers[t.id]) {
|
|
1688
|
+
orphanTimers[t.id] = setTimeout(function () {
|
|
1689
|
+
orphanTimers[t.id] = null;
|
|
1690
|
+
var cur = find(t.id);
|
|
1691
|
+
if (cur && !deleted[cur.id] && isOrphan(cur)) {
|
|
1692
|
+
deleted[cur.id] = 1;
|
|
1693
|
+
mutate({ op: "delete", id: cur.id });
|
|
1694
|
+
toast("Comment removed (its UI is gone)");
|
|
1695
|
+
}
|
|
1696
|
+
}, 700);
|
|
1697
|
+
}
|
|
1698
|
+
} else if (orphanTimers[t.id]) { clearTimeout(orphanTimers[t.id]); orphanTimers[t.id] = null; }
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
var reRenderT;
|
|
1703
|
+
function scheduleRerender() {
|
|
1704
|
+
clearTimeout(reRenderT);
|
|
1705
|
+
reRenderT = setTimeout(function () {
|
|
1706
|
+
if (state.active) renderList();
|
|
1707
|
+
renderPins(); // annotation pins repaint even with review off
|
|
1708
|
+
if (state.active) renderLinks(); // re-detect linked components after DOM/screen change
|
|
1709
|
+
orphanSweep();
|
|
1710
|
+
}, 200);
|
|
1711
|
+
}
|
|
1712
|
+
if (window.MutationObserver) {
|
|
1713
|
+
new MutationObserver(scheduleRerender).observe(document.documentElement, { childList: true, subtree: true });
|
|
1714
|
+
// Re-scope pins the instant the prototype switches screens (data-gv-screen).
|
|
1715
|
+
if (document.body) new MutationObserver(scheduleRerender)
|
|
1716
|
+
.observe(document.body, { attributes: true, attributeFilter: ["data-gv-screen"] });
|
|
1717
|
+
}
|
|
1718
|
+
window.addEventListener("scroll", reposition, { passive: true });
|
|
1719
|
+
// Resize can reflow layout (padding/gap/margin change), so rebuild the spacing shades;
|
|
1720
|
+
// scroll only translates the boxes, which the relative shading already tracks.
|
|
1721
|
+
window.addEventListener("resize", function () {
|
|
1722
|
+
reposition();
|
|
1723
|
+
if (state.active && state.drill >= 2) renderLinks();
|
|
1724
|
+
}, { passive: true });
|
|
1725
|
+
window.addEventListener("hashchange", function () { if (state.active) { render(); } tryOpenPending(); setTimeout(orphanSweep, 1200); });
|
|
1726
|
+
window.addEventListener("popstate", function () { if (state.active) { render(); } tryOpenPending(); setTimeout(orphanSweep, 1200); });
|
|
1727
|
+
|
|
1728
|
+
// Boot. Review mode always starts OFF — press Shift+C to turn it on. We do
|
|
1729
|
+
// NOT restore the last active state: sessionStorage survives reloads and
|
|
1730
|
+
// same-tab navigation, so restoring meant every prototype you opened in a tab
|
|
1731
|
+
// booted into comment mode once you'd toggled it on anywhere. (Deep-linking to
|
|
1732
|
+
// a specific comment still re-activates via tryOpenPending/SS_PENDING.)
|
|
1733
|
+
refresh().then(function () {
|
|
1734
|
+
var grace = function () { settled = true; orphanSweep(); };
|
|
1735
|
+
if (document.readyState === "complete") setTimeout(grace, 1500);
|
|
1736
|
+
else window.addEventListener("load", function () { setTimeout(grace, 1500); });
|
|
1737
|
+
});
|
|
1738
|
+
})();
|