sitelines 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/DESIGN.md +129 -0
  2. package/LICENSE +21 -0
  3. package/README.md +336 -0
  4. package/bin/sitelines.mjs +155 -0
  5. package/examples/demo-site/about/index.html +30 -0
  6. package/examples/demo-site/assets/auth.js +3 -0
  7. package/examples/demo-site/assets/site.css +27 -0
  8. package/examples/demo-site/blog/index.html +31 -0
  9. package/examples/demo-site/blog/introducing-meridian/index.html +30 -0
  10. package/examples/demo-site/changelog/index.html +30 -0
  11. package/examples/demo-site/contact/index.html +33 -0
  12. package/examples/demo-site/docs/api/index.html +31 -0
  13. package/examples/demo-site/docs/api/webhooks/index.html +30 -0
  14. package/examples/demo-site/docs/api/webhooks/retries/index.html +30 -0
  15. package/examples/demo-site/docs/index.html +32 -0
  16. package/examples/demo-site/docs/quickstart/index.html +31 -0
  17. package/examples/demo-site/index.html +37 -0
  18. package/examples/demo-site/legal/privacy/index.html +29 -0
  19. package/examples/demo-site/legal/terms/index.html +29 -0
  20. package/examples/demo-site/login/index.html +35 -0
  21. package/examples/demo-site/pricing/index.html +33 -0
  22. package/examples/demo-site/product/index.html +31 -0
  23. package/examples/demo-site/product/integrations/index.html +31 -0
  24. package/examples/demo-site/signup/index.html +35 -0
  25. package/examples/demo-site/status/index.html +15 -0
  26. package/examples/demo-site/thanks/index.html +21 -0
  27. package/package.json +53 -0
  28. package/scripts/install-skill.mjs +145 -0
  29. package/scripts/scan.mjs +399 -0
  30. package/scripts/serve.mjs +207 -0
  31. package/skill/AGENTS.md +61 -0
  32. package/skill/SKILL.md +170 -0
  33. package/skill/references/edit-protocol.md +57 -0
  34. package/viewer/app.js +1373 -0
  35. package/viewer/index.html +202 -0
  36. package/viewer/style.css +616 -0
package/viewer/app.js ADDED
@@ -0,0 +1,1373 @@
1
+ // sitelines viewer
2
+ const $ = (s) => document.querySelector(s);
3
+ const el = (t, c, txt) => { const n = document.createElement(t); if (c) n.className = c; if (txt != null) n.textContent = txt; return n; };
4
+ const SVGNS = 'http://www.w3.org/2000/svg';
5
+ const svgEl = (t, a = {}) => { const n = document.createElementNS(SVGNS, t); for (const k in a) n.setAttribute(k, a[k]); return n; };
6
+ // glyphs come from the sprite in index.html; see DESIGN.md on why there is no icon package
7
+ const icon = (id, cls = 'icon') => {
8
+ const s = svgEl('svg', { class: cls });
9
+ s.appendChild(svgEl('use', { href: `#${id}` }));
10
+ return s;
11
+ };
12
+ const iconBtn = (id, cls, label) => {
13
+ const b = el('button', cls);
14
+ b.type = 'button';
15
+ b.appendChild(icon(id, 'icon sm'));
16
+ b.setAttribute('aria-label', label);
17
+ b.title = label;
18
+ return b;
19
+ };
20
+
21
+ const COL = 330, ROW = 232, PAD = 96, W = 232, H = 188, WS = 172, HS = 74, PER_COL = 7;
22
+
23
+ const S = {
24
+ flow: null, edits: [], sel: null, linkFrom: null,
25
+ cfg: null, mode: 'site', // which view tab is active (from .sitelines/views.json)
26
+ cam: { x: 60, y: 60, k: 0.8 },
27
+ nodes: new Map(), pos: new Map(), dom: new Map(), io: new Map(), ioAll: new Map(), depth: new Map(), colX: new Map(),
28
+ edges: [], collapsed: new Set(), groups: new Map(),
29
+ // page JS is off by default: previews are the real pages, and one with a
30
+ // polling loop or a failing API call will peg the renderer 40 frames over.
31
+ filters: { shared: false, api: false, ext: false, preview: true, js: false },
32
+ q: '',
33
+ };
34
+
35
+ const CACHE_KEY = 'sitelines:cache:v1';
36
+ const THEME_KEY = 'sitelines:theme';
37
+
38
+ boot();
39
+
40
+ async function boot() {
41
+ wireUI();
42
+ // paint from the last session's map immediately, then refresh from the server
43
+ try {
44
+ const c = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null');
45
+ if (c && c.root === location.host + (c.flow?.root || '')) { paint(c.flow, c.cfg, c.edits || []); fit(); }
46
+ } catch { /* stale cache shape - ignore */ }
47
+ try {
48
+ await reload();
49
+ } catch (e) {
50
+ return fatal(e);
51
+ }
52
+ if (S.pos.size) fit();
53
+ }
54
+
55
+ // the server is the only source of the map; if it is gone, say so plainly
56
+ function fatal(e) {
57
+ const box = el('div', 'fatal');
58
+ box.appendChild(icon('i-alert'));
59
+ box.appendChild(el('b', null, 'Cannot reach the sitelines server'));
60
+ const msg = el('span');
61
+ msg.append('The viewer is open but ', el('code', null, 'sitelines serve'), ' is not answering. Restart it, then reload this page.');
62
+ box.appendChild(msg);
63
+ box.appendChild(el('code', null, String(e && e.message || e)));
64
+ document.body.appendChild(box);
65
+ }
66
+
67
+ async function reload() {
68
+ const grab = (u) => fetch(u).then((r) => { if (!r.ok) throw new Error(`${u} -> ${r.status}`); return r.json(); });
69
+ const [flow, edits, cfg] = await Promise.all([grab('/api/flow'), grab('/api/edits'), grab('/api/views')]);
70
+ const first = !S.flow;
71
+ paint(flow, cfg, edits);
72
+ try { localStorage.setItem(CACHE_KEY, JSON.stringify({ root: location.host + flow.root, flow, cfg, edits })); } catch { /* quota */ }
73
+ if (first && flow.nodes.filter((n) => n.type === 'page').length > 60) {
74
+ S.filters.preview = false; // past ~60 iframes even script-less previews drag
75
+ $('#fPreview').checked = false;
76
+ render();
77
+ toast('Large map, so previews are off. Turn them on under Layers, or click a page for its full preview.', true);
78
+ }
79
+ }
80
+
81
+ function paint(flow, cfg, edits) {
82
+ S.flow = flow; S.cfg = cfg; S.edits = edits;
83
+ S.mode = S.cfg.views.some((v) => v.id === S.mode) ? S.mode : (S.cfg.active || S.cfg.views[0].id);
84
+ S.nodes = new Map(S.flow.nodes.map((n) => [n.id, n]));
85
+ for (const e of pending('add-page')) if (!S.nodes.has(e.route)) {
86
+ S.nodes.set(e.route, { id: e.route, label: e.label || e.route, title: e.label || e.route, type: 'ghost', group: e.route.split('/').filter(Boolean)[0] || 'root', file: null });
87
+ }
88
+ $('#rootLabel').textContent = shortRoot(flow.root);
89
+ renderTabs();
90
+ recompute();
91
+ render();
92
+ renderRail();
93
+ renderEdits();
94
+ if (S.sel) select(S.sel, true);
95
+ }
96
+
97
+ /* ---------- view model ---------- */
98
+ const pending = (op) => S.edits.filter((e) => e.status !== 'applied' && (!op || e.op === op));
99
+ const view = () => S.cfg.views.find((v) => v.id === S.mode) || S.cfg.views[0];
100
+
101
+ // include/exclude rules are plain route patterns the user owns:
102
+ // /admin/** every route under it /admin/ that exact route
103
+ // /admin/* one level under it *report* substring
104
+ function matches(id, pat) {
105
+ if (!pat) return false;
106
+ if (pat.endsWith('/**')) { const p = pat.slice(0, -2); return id === p || id.startsWith(p); }
107
+ if (!pat.includes('*')) return id === pat || id === pat + '/';
108
+ const re = new RegExp('^' + pat.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
109
+ return re.test(id);
110
+ }
111
+ const excluded = (id) => {
112
+ const v = view();
113
+ if (v.include?.length && !v.include.some((p) => matches(id, p))) return true;
114
+ return !!v.exclude?.some((p) => matches(id, p));
115
+ };
116
+
117
+ function inView(n) {
118
+ if (n.type === 'api') return S.filters.api;
119
+ if (n.type === 'external') return S.filters.ext;
120
+ if (n.type === 'missing') return true;
121
+ return !excluded(n.id);
122
+ }
123
+
124
+ async function saveViews() {
125
+ S.cfg.active = S.mode;
126
+ renderTabs(); recompute(); render(); renderRail();
127
+ await fetch('/api/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(S.cfg) });
128
+ }
129
+
130
+ /* ---------- views ---------- */
131
+ // "everything" is the base view. It always shows every page and never takes a
132
+ // rule, so filtering while it is active creates a new view rather than quietly
133
+ // turning the one complete picture of the site into a partial one.
134
+ const isBase = (v) => !!(v || view()).base;
135
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'view';
136
+ const tidy = (pat) => String(pat).replace(/[/*]+/g, ' ').trim() || 'root';
137
+ const labelFor = (kind, pat) => (kind === 'include' ? tidy(pat) : `no ${tidy(pat)}`);
138
+
139
+ function newView(label, rule) {
140
+ const taken = new Set(S.cfg.views.map((v) => v.id));
141
+ let id = slug(label), i = 2;
142
+ while (taken.has(id)) id = `${slug(label)}-${i++}`;
143
+ const v = {
144
+ id, label,
145
+ include: rule && rule.kind === 'include' ? [rule.pat] : [],
146
+ exclude: rule && rule.kind === 'exclude' ? [rule.pat] : [],
147
+ collapsed: [],
148
+ };
149
+ S.cfg.views.push(v);
150
+ S.mode = id;
151
+ return v;
152
+ }
153
+
154
+ // one entry point for every "hide this" affordance, so the base-view rule holds
155
+ // no matter which control the user reached for
156
+ function addExcludeRule(pat, kind = 'exclude') {
157
+ if (isBase()) {
158
+ const v = newView(labelFor(kind, pat), { kind, pat });
159
+ toast(`“everything” always shows every page, so this went into a new view: ${v.label}`);
160
+ } else {
161
+ const v = view();
162
+ v[kind] = [...new Set([...(v[kind] || []), pat])];
163
+ if (kind === 'exclude') v.include = (v.include || []).filter((p) => p !== pat);
164
+ toast(`${view().label}: ${kind === 'include' ? 'only ' : 'hiding '}${pat}`);
165
+ }
166
+ saveViews();
167
+ }
168
+
169
+ async function promptNewView() {
170
+ const v = await modal('New view', [
171
+ { name: 'label', label: 'Name, for example “docs only” or “no admin”', value: '' },
172
+ { name: 'kind', label: 'First rule', type: 'select', options: ['hide these routes', 'show only these routes'], value: 'hide these routes' },
173
+ { name: 'pat', label: 'Route pattern (optional), for example /admin/**', value: '' },
174
+ ], 'Create view');
175
+ if (!v || !v.label.trim()) return;
176
+ const kind = v.kind.startsWith('show only') ? 'include' : 'exclude';
177
+ newView(v.label.trim(), v.pat.trim() ? { kind, pat: v.pat.trim() } : null);
178
+ S.sel = null; $('#inspect').hidden = true;
179
+ await saveViews();
180
+ fit();
181
+ }
182
+
183
+ async function deleteView() {
184
+ const v = view();
185
+ if (isBase(v)) return;
186
+ S.cfg.views = S.cfg.views.filter((x) => x !== v);
187
+ S.mode = 'all';
188
+ S.sel = null; $('#inspect').hidden = true;
189
+ toast(`Deleted the view “${v.label}”`);
190
+ await saveViews();
191
+ fit();
192
+ }
193
+
194
+ function renderLayerCount() {
195
+ const on = ['shared', 'api', 'ext'].filter((k) => S.filters[k]).length + (S.filters.js ? 1 : 0) + (S.filters.preview ? 0 : 1);
196
+ const b = $('#layerCount');
197
+ b.textContent = String(on);
198
+ b.hidden = !on;
199
+ }
200
+
201
+ function renderTabs() {
202
+ const box = $('#tabs'); box.textContent = '';
203
+ S.cfg.views.forEach((v, i) => {
204
+ const b = el('button', 'tab' + (v.id === S.mode ? ' on' : ''), v.label || v.id);
205
+ b.type = 'button';
206
+ b.dataset.view = v.id;
207
+ b.setAttribute('aria-pressed', String(v.id === S.mode));
208
+ b.title = v.base
209
+ ? `Every page, always. Filtering here creates a new view. (key ${i + 1})`
210
+ : `${v.include?.length ? 'Only ' + v.include.join(', ') : 'All routes'}${v.exclude?.length ? ', minus ' + v.exclude.join(', ') : ''} (key ${i + 1})`;
211
+ box.appendChild(b);
212
+ });
213
+ const add = el('button', 'tab add');
214
+ add.type = 'button';
215
+ add.dataset.add = '1';
216
+ add.appendChild(icon('i-plus', 'icon sm'));
217
+ add.title = 'New view: a saved subset of the map';
218
+ add.setAttribute('aria-label', 'New view');
219
+ box.appendChild(add);
220
+ }
221
+
222
+ // A link that appears on more than a handful of pages is chrome: a header, a
223
+ // footer, a nav partial. It is real, but drawing it once per page hides
224
+ // everything that is specific to a page, so it folds away by default.
225
+ const sitewide = (e) => e.kind === 'js-shared' || e.repeated === true;
226
+
227
+ const visibleEdge = (e) => (!sitewide(e) || S.filters.shared)
228
+ && S.nodes.has(e.from) && S.nodes.has(e.to) && inView(S.nodes.get(e.from)) && inView(S.nodes.get(e.to));
229
+
230
+ function allEdges() {
231
+ const removed = new Set(pending('remove-link').map((e) => `${e.from}>${e.to}>${e.label}`));
232
+ const base = S.flow.edges
233
+ .filter((e) => !removed.has(`${e.from}>${e.to}>${e.label}`))
234
+ .map((e) => {
235
+ const rt = pending('retarget').find((r) => r.from === e.from && r.to === e.to && r.label === e.label);
236
+ const rl = pending('relabel').find((r) => r.from === e.from && r.to === e.to && r.oldLabel === e.label);
237
+ return rt || rl ? { ...e, to: rt ? rt.newTo : e.to, label: rl ? rl.label : e.label, status: 'edited' } : e;
238
+ });
239
+ const proposed = pending('add-link').map((e, i) => ({
240
+ id: 'p' + i, from: e.from, to: e.to, label: e.label || 'new', via: e.via || 'link', kind: 'proposed', status: 'proposed', file: null, line: null,
241
+ }));
242
+ return [...base, ...proposed];
243
+ }
244
+
245
+ /* ---------- sections (group backdrops, collapsible) ---------- */
246
+ const groupOf = (n) => n.group || 'root';
247
+ const groupId = (g) => `section:${g}`;
248
+ const isGroup = (n) => n.type === 'section';
249
+ const collapsedSet = () => new Set(view().collapsed || []);
250
+
251
+ function toggleSection(g) {
252
+ const v = view();
253
+ const set = new Set(v.collapsed || []);
254
+ set.has(g) ? set.delete(g) : set.add(g);
255
+ v.collapsed = [...set];
256
+ if (S.sel && (S.sel === groupId(g) || S.nodes.get(S.sel)?.group === g)) { S.sel = null; $('#inspect').hidden = true; }
257
+ saveViews();
258
+ }
259
+
260
+ // every page in a collapsed section renders as one card; its internal links fold away
261
+ function renderId(id) {
262
+ const n = S.nodes.get(id);
263
+ if (!n || n.type !== 'page') return id;
264
+ const g = groupOf(n);
265
+ return S.collapsed.has(g) && inView(n) ? groupId(g) : id;
266
+ }
267
+
268
+ // collapse links into what a card actually shows: one wire per neighbour for a
269
+ // collapsed directory, one per control otherwise
270
+ function fold(list) {
271
+ const seenEdge = new Map();
272
+ const edges = [];
273
+ for (const e of list) {
274
+ const from = renderId(e.from), to = renderId(e.to);
275
+ if (from === to) continue; // link inside a collapsed section
276
+ const folded = from !== e.from || to !== e.to;
277
+ const k = folded ? `${from}>${to}` : `${from}>${to}>${e.label}`;
278
+ const hit = seenEdge.get(k);
279
+ if (hit) { hit.details.push(e); continue; }
280
+ const edge = folded
281
+ ? { ...e, from, to, folded: true, kind: 'folded', details: [e] }
282
+ : { ...e, details: [e] };
283
+ seenEdge.set(k, edge);
284
+ edges.push(edge);
285
+ }
286
+ for (const e of edges) {
287
+ if (!e.folded) continue;
288
+ const n = e.details.length;
289
+ e.label = n === 1 ? e.details[0].label : `${n} links`;
290
+ }
291
+ return edges;
292
+ }
293
+
294
+ function ioOf(edges) {
295
+ const io = new Map();
296
+ for (const n of S.nodes.values()) if (inView(n) && renderId(n.id) === n.id) io.set(n.id, { in: [], out: [] });
297
+ for (const s of S.groups.values()) io.set(s.id, { in: [], out: [] });
298
+ for (const e of edges) { io.get(e.from)?.out.push(e); io.get(e.to)?.in.push(e); }
299
+ return io;
300
+ }
301
+
302
+ function recompute() {
303
+ S.collapsed = collapsedSet();
304
+ S.groups = new Map();
305
+ for (const n of S.nodes.values()) {
306
+ if (n.type !== 'page' || !inView(n)) continue;
307
+ const g = groupOf(n);
308
+ if (!S.collapsed.has(g)) continue;
309
+ if (!S.groups.has(g)) S.groups.set(g, { id: groupId(g), label: g, title: `/${g}/`, type: 'section', group: g, members: [], file: null });
310
+ S.groups.get(g).members.push(n);
311
+ }
312
+
313
+ const all = allEdges().filter((e) => S.nodes.has(e.from) && S.nodes.has(e.to)
314
+ && inView(S.nodes.get(e.from)) && inView(S.nodes.get(e.to)));
315
+
316
+ // Two sets, deliberately. S.edges / S.io are what is DRAWN, so the wires and
317
+ // the port dots agree with each other. S.ioAll is what EXISTS in this view,
318
+ // and every judgement comes from it: a page reachable only through the footer
319
+ // is reachable, and calling it an orphan because the footer is folded away
320
+ // would be the tool lying about the site.
321
+ S.edges = fold(all.filter(visibleEdge));
322
+ S.io = ioOf(S.edges);
323
+ S.ioAll = ioOf(fold(all));
324
+
325
+ const entry = entryNode();
326
+ S.depth = new Map();
327
+ if (entry) {
328
+ S.depth.set(entry, 0);
329
+ const q = [entry];
330
+ while (q.length) {
331
+ const cur = q.shift();
332
+ for (const e of S.ioAll.get(cur)?.out || []) if (!S.depth.has(e.to)) { S.depth.set(e.to, S.depth.get(cur) + 1); q.push(e.to); }
333
+ }
334
+ }
335
+ layout();
336
+ }
337
+
338
+ // everything that gets its own card: visible nodes not folded into a section,
339
+ // plus one card per collapsed section
340
+ const renderNodes = () => [
341
+ ...[...S.nodes.values()].filter((n) => inView(n) && renderId(n.id) === n.id),
342
+ ...S.groups.values(),
343
+ ];
344
+
345
+ // where a visit starts: the site root if it is in this view, else the shallowest
346
+ // route, tie-broken by how many exits it has
347
+ function entryNode() {
348
+ const vis = renderNodes().filter((n) => (n.type === 'page' || n.type === 'ghost' || isGroup(n)));
349
+ if (!vis.length) return null;
350
+ if (view().entry && vis.some((n) => n.id === view().entry)) return view().entry;
351
+ if (vis.some((n) => n.id === '/')) return '/';
352
+ const depth = (id) => id.split('/').filter(Boolean).length;
353
+ const io = S.ioAll || S.io;
354
+ return vis.slice().sort((a, b) => depth(a.id) - depth(b.id)
355
+ || (io.get(b.id)?.out.length || 0) - (io.get(a.id)?.out.length || 0))[0].id;
356
+ }
357
+
358
+ /* ---------- layout ---------- */
359
+ function layout() {
360
+ S.pos = new Map();
361
+ const saved = S.flow.layout || {};
362
+ const cols = new Map();
363
+ const nodes = renderNodes();
364
+ const depths = nodes.filter((n) => n.type === 'page' || n.type === 'ghost' || isGroup(n)).map((n) => S.depth.get(n.id) ?? -1);
365
+ const maxDepth = Math.max(0, ...depths);
366
+ for (const n of nodes) {
367
+ const d = S.depth.get(n.id);
368
+ let c;
369
+ if (n.type === 'page' || n.type === 'ghost' || isGroup(n)) c = d == null ? maxDepth + 1 : d;
370
+ else c = maxDepth + 3 + (n.type === 'external' ? 1 : 0);
371
+ if (!cols.has(c)) cols.set(c, []);
372
+ cols.get(c).push(n);
373
+ }
374
+ S.colX = new Map();
375
+ S.maxPageCol = maxDepth;
376
+ let x = PAD;
377
+ for (const c of [...cols.keys()].sort((a, b) => a - b)) {
378
+ // section members sit next to each other so a section's backdrop stays tidy
379
+ const list = cols.get(c).sort((a, b) => groupOf(a).localeCompare(groupOf(b))
380
+ || (S.io.get(b.id)?.in.length || 0) - (S.io.get(a.id)?.in.length || 0) || a.id.localeCompare(b.id));
381
+ S.colX.set(c, x);
382
+ const subs = Math.max(1, Math.ceil(list.length / PER_COL));
383
+ list.forEach((n, i) => {
384
+ const small = (n.type !== 'page' && n.type !== 'ghost' && !isGroup(n));
385
+ const sub = Math.floor(i / PER_COL), row = i % PER_COL;
386
+ const p = saved[n.id] || { x: x + sub * (W + 60), y: PAD + row * (small ? 108 : ROW) };
387
+ S.pos.set(n.id, { ...p, w: small ? WS : W, h: small ? HS : H, col: c });
388
+ });
389
+ x += subs * (W + 60) + COL - W - 20;
390
+ }
391
+ }
392
+
393
+ /* ---------- render ---------- */
394
+ function render() {
395
+ const host = $('#nodes'), wires = $('#wires');
396
+ host.textContent = ''; wires.textContent = '';
397
+ S.dom = new Map();
398
+
399
+ drawColumns(wires);
400
+ drawSections(host);
401
+ for (const [id, p] of S.pos) host.appendChild(pageCard(nodeOf(id), p));
402
+ drawWires();
403
+ applyView();
404
+ applyFilterDim();
405
+ $('#empty').hidden = S.pos.size > 0;
406
+ }
407
+
408
+ const nodeOf = (id) => S.nodes.get(id) || S.groups.get(String(id).replace(/^section:/, ''));
409
+
410
+ // the drafting rule: one guide and one label per depth column
411
+ function drawColumns(wires) {
412
+ const bounds = new Map();
413
+ for (const [, p] of S.pos) {
414
+ const b = bounds.get(p.col) || { top: 1e9, bottom: -1e9 };
415
+ b.top = Math.min(b.top, p.y); b.bottom = Math.max(b.bottom, p.y + p.h);
416
+ bounds.set(p.col, b);
417
+ }
418
+ const ordered = [...S.colX.entries()].sort((a, b) => a[0] - b[0]);
419
+ for (const [c, b] of bounds) {
420
+ const x = S.colX.get(c) ?? 0;
421
+ const next = ordered.find(([cc]) => cc > c);
422
+ const room = (next ? next[1] : x + 420) - x - 24;
423
+ const sample = [...S.pos.entries()].find(([, p]) => p.col === c);
424
+ const n = sample && nodeOf(sample[0]);
425
+ const full = n && (n.type === 'api' || n.type === 'external') ? n.type.toUpperCase()
426
+ : c > S.maxPageCol ? 'UNREACHABLE BY CLICKING'
427
+ : c === 0 ? 'DEPTH 0 · ENTRY' : `DEPTH ${c}`;
428
+
429
+ wires.appendChild(svgEl('line', { class: 'col-guide', x1: x - 22, y1: b.top - 52, x2: x - 22, y2: b.bottom + 12 }));
430
+ const t = svgEl('text', { class: 'col-label', x, y: b.top - 58 });
431
+ t.textContent = full;
432
+ wires.appendChild(t);
433
+ // measure, then trim until the label fits its own column
434
+ let text = full;
435
+ while (text.length > 4 && t.getComputedTextLength() > room) {
436
+ text = text.slice(0, -2);
437
+ t.textContent = text + '…';
438
+ }
439
+ if (text !== full) t.appendChild(svgEl('title')).textContent = full;
440
+ }
441
+ }
442
+
443
+ // Backdrop behind a directory's pages. One box per vertical run of same-directory
444
+ // cards, so a box only ever contains that directory. A single box spanning a
445
+ // group's full bounding rectangle would swallow every unrelated card standing
446
+ // between its first and last page.
447
+ function drawSections(host) {
448
+ const boxes = new Map(); // `${group}|${x}` -> box, one per column of cards
449
+ const total = new Map(); // group -> how many of its pages are on screen
450
+ for (const [id, p] of S.pos) {
451
+ const n = nodeOf(id);
452
+ if (!n || isGroup(n) || (n.type !== 'page' && n.type !== 'ghost')) continue;
453
+ const g = groupOf(n);
454
+ total.set(g, (total.get(g) || 0) + 1);
455
+ const k = `${g}|${Math.round(p.x)}`;
456
+ const b = boxes.get(k) || { g, x1: 1e9, y1: 1e9, x2: -1e9, y2: -1e9, n: 0 };
457
+ b.x1 = Math.min(b.x1, p.x); b.y1 = Math.min(b.y1, p.y);
458
+ b.x2 = Math.max(b.x2, p.x + p.w); b.y2 = Math.max(b.y2, p.y + p.h); b.n++;
459
+ boxes.set(k, b);
460
+ }
461
+ const drawn = [...boxes.values()].filter((b) => b.n > 1);
462
+ // only the leftmost, then topmost, box of a directory carries its label
463
+ const labelBox = new Map();
464
+ for (const b of drawn) {
465
+ const cur = labelBox.get(b.g);
466
+ if (!cur || b.x1 < cur.x1 || (b.x1 === cur.x1 && b.y1 < cur.y1)) labelBox.set(b.g, b);
467
+ }
468
+
469
+ for (const b of drawn) {
470
+ const z = el('div', 'section');
471
+ z.style.cssText = `left:${b.x1 - 16}px;top:${b.y1 - 42}px;width:${b.x2 - b.x1 + 32}px;height:${b.y2 - b.y1 + 58}px`;
472
+ if (labelBox.get(b.g) === b) {
473
+ const head = el('button', 'section-head');
474
+ head.type = 'button';
475
+ head.appendChild(icon('i-chev-down', 'icon'));
476
+ head.appendChild(el('span', null, `/${b.g === 'root' ? '' : b.g + '/'}`));
477
+ head.appendChild(el('span', 'k', `${total.get(b.g)} pages`));
478
+ head.title = 'Collapse this directory into one card';
479
+ head.onclick = (ev) => { ev.stopPropagation(); toggleSection(b.g); };
480
+ z.appendChild(head);
481
+ }
482
+ host.appendChild(z);
483
+ }
484
+ }
485
+
486
+ function pageCard(n, p) {
487
+ const io = S.io.get(n.id) || { in: [], out: [] }; // drawn: ports must match wires
488
+ const tot = S.ioAll.get(n.id) || io; // real: counts and tags
489
+ if (isGroup(n)) return groupCard(n, p, io, tot);
490
+ const d = el('div', `node kind-${n.type}`);
491
+ d.style.cssText = `left:${p.x}px;top:${p.y}px;width:${p.w}px;--in:${Math.min(p.col * 40, 320)}ms`;
492
+ d.dataset.id = n.id;
493
+ d.tabIndex = 0;
494
+ d.setAttribute('aria-label', `${n.id}, ${tot.in.length} entrances, ${tot.out.length} exits`);
495
+
496
+ const h = el('div', 'node-head');
497
+ h.appendChild(el('span', 'node-route', n.type === 'page' || n.type === 'ghost' ? (n.label || '/') : n.id));
498
+ h.appendChild(el('span', 'node-badge', n.type === 'page' || n.type === 'ghost' ? `${tot.in.length} in · ${tot.out.length} out` : n.type));
499
+ d.appendChild(h);
500
+
501
+ if (n.type === 'page' || n.type === 'ghost') {
502
+ const t = el('div', 'node-thumb');
503
+ if (n.type === 'ghost') t.appendChild(el('div', 'ph', 'Proposed page'));
504
+ else if (S.filters.preview) { t.appendChild(el('div', 'ph skeleton')); lazyPreview(t, n.id); }
505
+ else t.appendChild(el('div', 'ph', n.id));
506
+ d.appendChild(t);
507
+ const f = el('div', 'node-foot');
508
+ f.appendChild(el('span', null, n.id));
509
+ if (!tot.in.length && n.id !== entryNode()) f.appendChild(el('span', 'tag warn', 'orphan'));
510
+ if (!tot.out.length) f.appendChild(el('span', 'tag', 'dead end'));
511
+ d.appendChild(f);
512
+ d.appendChild(ports(io.in, 'in'));
513
+ d.appendChild(ports(io.out, 'out'));
514
+ }
515
+ S.dom.set(n.id, d);
516
+ return d;
517
+ }
518
+
519
+ function groupCard(n, p, io, tot) {
520
+ const d = el('div', 'node is-group');
521
+ d.style.cssText = `left:${p.x}px;top:${p.y}px;width:${p.w}px;--in:${Math.min(p.col * 40, 320)}ms`;
522
+ d.dataset.id = n.id;
523
+ d.tabIndex = 0;
524
+ const h = el('div', 'node-head');
525
+ h.appendChild(icon('i-chev-right', 'icon sm'));
526
+ h.appendChild(el('span', 'node-route', `/${n.label}/`));
527
+ h.appendChild(el('span', 'node-badge', `${tot.in.length} in · ${tot.out.length} out`));
528
+ d.appendChild(h);
529
+
530
+ const body = el('div', 'group-body');
531
+ body.appendChild(el('span', 'group-count', String(n.members.length)));
532
+ body.appendChild(el('span', 'muted', 'pages\ncollapsed'));
533
+ const btn = el('button', 'btn sm', 'Expand');
534
+ btn.type = 'button';
535
+ btn.onclick = (ev) => { ev.stopPropagation(); toggleSection(n.group); };
536
+ body.appendChild(btn);
537
+ d.appendChild(body);
538
+
539
+ const f = el('div', 'node-foot');
540
+ f.appendChild(el('span', null, n.members.slice(0, 3).map((m) => m.label).join(', ') + (n.members.length > 3 ? '…' : '')));
541
+ d.appendChild(f);
542
+ d.appendChild(ports(io.in, 'in'));
543
+ d.appendChild(ports(io.out, 'out'));
544
+ S.dom.set(n.id, d);
545
+ return d;
546
+ }
547
+
548
+ // Port geometry, mirrored from .ports / .port in style.css. A wire ends exactly
549
+ // on the dot that represents it, so the dots are the wire's real terminals
550
+ // rather than decoration beside them. Keep these three in sync with the CSS.
551
+ const PORT_TOP = 30, PORT_STEP = 14, PORT_R = 4.5, PORT_CAP = 7;
552
+ const PORTED = new Set(['page', 'ghost', 'section']);
553
+
554
+ // where on a card's edge this particular link attaches
555
+ function anchor(id, list, edge, side) {
556
+ const p = S.pos.get(id);
557
+ const n = nodeOf(id);
558
+ const x = side === 'out' ? p.x + p.w : p.x;
559
+ if (!n || !PORTED.has(n.type)) return { x, y: p.y + p.h / 2 }; // api/external cards show no ports
560
+ const i = Math.max(0, list.indexOf(edge));
561
+ // everything past the cap collapses onto the small "more" dot, exactly as drawn
562
+ if (i >= PORT_CAP) return { x, y: p.y + PORT_TOP + PORT_CAP * PORT_STEP + 2.5 };
563
+ return { x, y: p.y + PORT_TOP + i * PORT_STEP + PORT_R };
564
+ }
565
+
566
+ // the dots down each side of a card: entrances on the left, exits on the right
567
+ function ports(list, dir) {
568
+ const wrap = el('div', 'ports ' + dir);
569
+ for (const e of list.slice(0, 7)) {
570
+ const dot = el('button', 'port ' + dir + (e.status === 'proposed' ? ' proposed' : ''));
571
+ dot.type = 'button';
572
+ dot.dataset.tip = `${dir === 'out' ? 'EXIT' : 'ENTRANCE'} · “${e.label}”\n${e.from} → ${e.to}\n${e.via}${e.file ? ` · ${e.file}:${e.line}` : ''}`;
573
+ dot.dataset.go = dir === 'out' ? e.to : e.from;
574
+ dot.dataset.wire = `${e.from}>${e.to}`;
575
+ dot.setAttribute('aria-label', `${dir === 'out' ? 'Exit' : 'Entrance'}: ${e.label}, ${e.from} to ${e.to}`);
576
+ wrap.appendChild(dot);
577
+ }
578
+ if (list.length > 7) {
579
+ const more = el('i', 'port more ' + dir);
580
+ more.dataset.tip = `${list.length - 7} more`;
581
+ wrap.appendChild(more);
582
+ }
583
+ return wrap;
584
+ }
585
+
586
+ // Thumbnails are real pages: mount few, drop the ones scrolled away from.
587
+ // Without the cap the browser composites 40+ documents on every pan.
588
+ const MOUNT_CAP = 12;
589
+ const io_ = new IntersectionObserver((es) => {
590
+ for (const e of es) {
591
+ if (e.isIntersecting) enqueuePreview(e.target);
592
+ else unmountPreview(e.target);
593
+ }
594
+ }, { root: null, rootMargin: '150px' });
595
+
596
+ const Q = { list: [], live: 0, max: 2, mounted: [] };
597
+
598
+ function unmountPreview(t) {
599
+ Q.list = Q.list.filter((x) => x !== t);
600
+ const f = t.querySelector('iframe');
601
+ if (!f) return;
602
+ if (t._done) t._done(); // still loading: free its slot or the queue stalls
603
+ f.remove();
604
+ Q.mounted = Q.mounted.filter((x) => x !== t);
605
+ if (!t.querySelector('.ph')) t.appendChild(el('div', 'ph', t.dataset.route));
606
+ }
607
+
608
+ function evict() {
609
+ while (Q.mounted.length > MOUNT_CAP) unmountPreview(Q.mounted.shift());
610
+ }
611
+ const sandbox = () => (S.filters.js ? 'allow-scripts allow-same-origin' : '');
612
+ function lazyPreview(t, route) { t.dataset.route = route; io_.observe(t); }
613
+ function enqueuePreview(t) {
614
+ if (t.querySelector('iframe') || Q.list.includes(t)) return;
615
+ Q.list.push(t); pump();
616
+ }
617
+ function pump() {
618
+ while (Q.live < Q.max && Q.list.length) {
619
+ const t = Q.list.shift();
620
+ if (!t.isConnected || t.querySelector('iframe')) continue;
621
+ Q.live++;
622
+ Q.mounted.push(t); evict();
623
+ const f = el('iframe');
624
+ f.setAttribute('sandbox', sandbox());
625
+ f.setAttribute('title', `Preview of ${t.dataset.route}`);
626
+ f.setAttribute('tabindex', '-1');
627
+ f.src = '/site' + t.dataset.route;
628
+ let settled = false;
629
+ const done = () => { if (settled) return; settled = true; t._done = null; t.querySelector('.ph')?.remove(); Q.live--; setTimeout(pump, 60); };
630
+ t._done = done;
631
+ f.addEventListener('load', done, { once: true });
632
+ f.addEventListener('error', done, { once: true });
633
+ setTimeout(done, 8000); // never let one slow page stall the queue
634
+ t.appendChild(f);
635
+ }
636
+ }
637
+
638
+ let wireRaf = 0;
639
+ const queueWires = () => { if (!wireRaf) wireRaf = requestAnimationFrame(() => { wireRaf = 0; drawWires(); }); };
640
+
641
+ function drawWires() {
642
+ const wires = $('#wires');
643
+ for (const w of wires.querySelectorAll('.wire,.wlabel,.hit')) w.remove();
644
+ if (!wires.querySelector('defs')) {
645
+ const defs = svgEl('defs');
646
+ const m = svgEl('marker', { id: 'ah', viewBox: '0 0 8 8', refX: '7', refY: '4', markerWidth: '5', markerHeight: '5', orient: 'auto-start-reverse' });
647
+ m.appendChild(svgEl('path', { d: 'M0,0 L8,4 L0,8', fill: 'none', stroke: 'context-stroke', 'stroke-width': '1.6' }));
648
+ defs.appendChild(m);
649
+ // a form submit gets a bar terminal at its source, so kind stays readable
650
+ // without spending another hue on it
651
+ const fb = svgEl('marker', { id: 'fb', viewBox: '0 0 4 8', refX: '1', refY: '4', markerWidth: '4', markerHeight: '6', orient: 'auto' });
652
+ fb.appendChild(svgEl('path', { d: 'M1,0 L1,8', fill: 'none', stroke: 'context-stroke', 'stroke-width': '2' }));
653
+ defs.appendChild(fb);
654
+ wires.appendChild(defs);
655
+ }
656
+ const seen = new Map();
657
+ for (const e of S.edges) {
658
+ const a = S.pos.get(e.from), b = S.pos.get(e.to);
659
+ if (!a || !b) continue;
660
+ const key = `${e.from}>${e.to}`;
661
+ const n = seen.get(key) || 0; seen.set(key, n + 1);
662
+ // start and end on the ports themselves; no synthetic fan-out offset is
663
+ // needed because two links out of one page already own two different dots
664
+ const A = anchor(e.from, S.io.get(e.from)?.out || [], e, 'out');
665
+ const B = anchor(e.to, S.io.get(e.to)?.in || [], e, 'in');
666
+ const x1 = A.x, y1 = A.y, x2 = B.x, y2 = B.y;
667
+ const dx = Math.max(60, Math.abs(x2 - x1) * 0.45);
668
+ const d = `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
669
+ const data = { 'data-from': e.from, 'data-to': e.to, 'data-wire': key };
670
+ const attrs = { class: `wire ${cls(e)}`, d, 'marker-end': 'url(#ah)', ...data };
671
+ if (e.via === 'form') attrs['marker-start'] = 'url(#fb)';
672
+ wires.appendChild(svgEl('path', attrs));
673
+ // fat transparent path on top so the thin wire is actually hoverable
674
+ const hit = svgEl('path', { class: 'hit', d, ...data });
675
+ hit.dataset.tip = e.folded
676
+ ? `${e.label}: ${clean(e.from)} → ${clean(e.to)}\n` + e.details.slice(0, 6).map((x) => `· “${x.label}” ${x.from} → ${x.to}`).join('\n')
677
+ + (e.details.length > 6 ? `\n· ${e.details.length - 6} more` : '')
678
+ : `“${e.label}”\n${e.from} → ${e.to}\n${e.via} · ${e.kind}${e.file ? ` · ${e.file}:${e.line}` : ''}`;
679
+ wires.appendChild(hit);
680
+ if (n < 2) {
681
+ const lab = svgEl('text', { class: 'wlabel', x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 4, 'text-anchor': 'middle', ...data });
682
+ lab.textContent = trunc(e.label, 26);
683
+ wires.appendChild(lab);
684
+ }
685
+ }
686
+ }
687
+
688
+ // hue = state, stroke style = kind
689
+ function cls(e) {
690
+ const out = [];
691
+ if (e.kind === 'proposed') out.push('proposed');
692
+ else if (S.nodes.get(e.to)?.type === 'missing') out.push('dead');
693
+ if (e.via === 'form') out.push('form');
694
+ else if (/js/.test(e.kind)) out.push('js');
695
+ if (e.via === 'redirect') out.push('redirect');
696
+ return out.join(' ');
697
+ }
698
+
699
+ function applyView() {
700
+ $('#canvas').style.transform = `translate(${S.cam.x}px,${S.cam.y}px) scale(${S.cam.k})`;
701
+ // zoomed out, wire labels and thumbnails are unreadable anyway - drop them
702
+ // from the render tree instead of compositing them
703
+ $('#stage').dataset.detail = S.cam.k < 0.26 ? 'low' : 'high';
704
+ }
705
+
706
+ function applyFilterDim() {
707
+ const q = S.q.toLowerCase();
708
+ for (const [id, d] of S.dom) {
709
+ const n = nodeOf(id);
710
+ const hit = !q || id.toLowerCase().includes(q) || (n?.title || '').toLowerCase().includes(q);
711
+ d.classList.toggle('dim', !hit);
712
+ d.classList.toggle('sel', id === S.sel);
713
+ }
714
+ for (const w of $('#wires').querySelectorAll('.wire,.wlabel')) {
715
+ const hot = S.sel && (w.dataset.from === S.sel || w.dataset.to === S.sel);
716
+ w.classList.toggle('hot', !!hot);
717
+ w.classList.toggle('faint', !!S.sel && !hot);
718
+ }
719
+ }
720
+
721
+ /* ---------- rail ---------- */
722
+ function renderRail() {
723
+ const pages = [...S.nodes.values()].filter((n) => inView(n) && n.type === 'page' && renderId(n.id) === n.id);
724
+ const depths = pages.map((n) => S.depth.get(n.id)).filter((d) => d != null);
725
+ const box = $('#stats'); box.textContent = '';
726
+ const hiddenChrome = S.filters.shared ? 0 : allEdges().filter(sitewide).length;
727
+ const rows = [
728
+ ['View', view().label || S.mode],
729
+ ['Pages', `${pages.length} of ${S.flow.nodes.filter((n) => n.type === 'page').length}`],
730
+ ['Links drawn', hiddenChrome ? `${S.edges.length} of ${S.edges.length + hiddenChrome}` : String(S.edges.length)],
731
+ ['Deepest', depths.length ? `${Math.max(...depths)} clicks` : '0 clicks'],
732
+ ['Scanned', (S.flow.generatedAt || '').slice(0, 16).replace('T', ' ')],
733
+ ];
734
+ for (const [k, v] of rows) {
735
+ const r = el('div', 'stat');
736
+ r.appendChild(el('span', null, k));
737
+ r.appendChild(el('b', null, String(v)));
738
+ box.appendChild(r);
739
+ }
740
+
741
+ // ENTRY POINTS - where a visit can start
742
+ const entry = entryNode();
743
+ const ins = pages.filter((n) => n.id === entry || !(S.ioAll.get(n.id)?.in.length));
744
+ $('#inCount').textContent = ins.length;
745
+ fillList('#inputs', ins.map((n) => ({
746
+ label: n.id, sub: n.id === entry ? 'front door' : 'direct URL only', go: n.id, warn: n.id !== entry,
747
+ })), 'Nothing can start a visit in this view.');
748
+
749
+ // LEAVES THE SITE - off-site links, API calls, and broken targets
750
+ const outs = new Map();
751
+ for (const e of S.edges) {
752
+ const t = S.nodes.get(e.to);
753
+ if (!t || (t.type !== 'external' && t.type !== 'api' && t.type !== 'missing')) continue;
754
+ if (!outs.has(e.to)) outs.set(e.to, { n: 0, type: t.type });
755
+ outs.get(e.to).n++;
756
+ }
757
+ const outRows = [...outs.entries()].sort((a, b) => b[1].n - a[1].n);
758
+ $('#outCount').textContent = outRows.length;
759
+ fillList('#outputs', outRows.map(([id, v]) => ({
760
+ label: id, sub: `${v.type} · ${v.n} link${v.n > 1 ? 's' : ''}`, go: id, warn: v.type === 'missing',
761
+ })), 'Nothing leaves the site in this view. Turn on API endpoints or external links under Layers.');
762
+
763
+ renderExcluded();
764
+ renderIssues(pages, entry);
765
+ }
766
+
767
+ function renderIssues(pages, entry) {
768
+ const i = S.flow.issues || {};
769
+ const shown = new Set(pages.map((n) => n.id));
770
+ const box = $('#issues'); box.textContent = '';
771
+ let count = 0;
772
+ const add = (bad, title, sub, go) => {
773
+ count++;
774
+ const d = el('button', 'issue' + (bad ? ' bad' : ''));
775
+ d.type = 'button';
776
+ d.appendChild(icon(bad ? 'i-broken' : 'i-alert', 'icon sm'));
777
+ d.appendChild(el('b', null, title));
778
+ d.appendChild(el('small', null, sub));
779
+ if (go) d.onclick = () => select(go);
780
+ box.appendChild(d);
781
+ };
782
+ for (const d of (i.deadLinks || []).filter((d) => shown.has(d.from)).slice(0, 12)) {
783
+ add(true, `Dead link to ${d.to}`, `${d.from} · “${d.label}”${d.file ? ` · ${d.file}:${d.line}` : ''}`, d.from);
784
+ }
785
+ for (const n of pages.filter((n) => !(S.ioAll.get(n.id)?.in.length) && n.id !== entry).slice(0, 10)) {
786
+ add(false, `Orphan ${n.id}`, 'No inbound link, unreachable by clicking', n.id);
787
+ }
788
+ for (const n of pages.filter((n) => S.depth.get(n.id) == null && n.id !== entry).slice(0, 10)) {
789
+ add(false, `Unreachable ${n.id}`, `Not reachable from ${entry}`, n.id);
790
+ }
791
+ for (const n of pages.filter((n) => (S.depth.get(n.id) ?? 0) > 3).slice(0, 8)) {
792
+ add(false, `Deep ${n.id}`, `${S.depth.get(n.id)} clicks from the entry`, n.id);
793
+ }
794
+ for (const n of pages.filter((n) => !(S.ioAll.get(n.id)?.out.length)).slice(0, 8)) {
795
+ add(false, `Dead end ${n.id}`, 'No way out, not even back', n.id);
796
+ }
797
+ for (const n of pages.filter((n) => (S.ioAll.get(n.id)?.out.length || 0) > 12).slice(0, 5)) {
798
+ add(false, `Hub ${n.id}`, `${S.ioAll.get(n.id).out.length} exits, consider grouping`, n.id);
799
+ }
800
+ $('#issueCount').textContent = count || '';
801
+ if (!count) {
802
+ const ok = el('div', 'empty-note');
803
+ ok.append('No dead links, orphans, or dead ends in this view.');
804
+ box.appendChild(ok);
805
+ }
806
+ }
807
+
808
+ // what this view leaves out: the rules first (removable), then the pages they hide
809
+ function renderExcluded() {
810
+ const v = view();
811
+ const box = $('#excluded'); box.textContent = '';
812
+ const hidden = S.flow.nodes.filter((n) => n.type === 'page' && excluded(n.id));
813
+ $('#exCount').textContent = hidden.length;
814
+ $('#viewDelete').hidden = isBase(v);
815
+ if (isBase(v)) {
816
+ box.appendChild(el('div', 'empty-note', 'The base view always shows every page. Add a rule below, or press + on the view tabs, to start a filtered view of your own.'));
817
+ return;
818
+ }
819
+
820
+ for (const kind of ['include', 'exclude']) {
821
+ for (const pat of v[kind] || []) {
822
+ const row = el('div', 'row rule');
823
+ row.appendChild(el('span', 'k', kind === 'include' ? 'only' : 'hide'));
824
+ row.appendChild(el('span', null, pat));
825
+ const x = iconBtn('i-close', 'x', `Remove rule ${pat}`);
826
+ x.onclick = () => { v[kind] = v[kind].filter((p) => p !== pat); saveViews(); };
827
+ row.appendChild(x);
828
+ box.appendChild(row);
829
+ }
830
+ }
831
+ if (!hidden.length && !(v.include || []).length && !(v.exclude || []).length) {
832
+ box.appendChild(el('div', 'empty-note', 'Nothing hidden. This view shows every page.'));
833
+ return;
834
+ }
835
+ const cap = S.showAllExcluded ? hidden.length : 6;
836
+ for (const n of hidden.slice(0, cap)) {
837
+ const row = el('div', 'row gone');
838
+ row.appendChild(el('span', null, n.id));
839
+ const back = iconBtn('i-plus', 'x add', `Show ${n.id} in this view`);
840
+ back.onclick = () => includeBack(n.id);
841
+ row.appendChild(back);
842
+ box.appendChild(row);
843
+ }
844
+ if (hidden.length > cap) {
845
+ const more = el('button', 'btn sm', `Show ${hidden.length - cap} more`);
846
+ more.type = 'button';
847
+ more.onclick = () => { S.showAllExcluded = true; renderExcluded(); };
848
+ box.appendChild(more);
849
+ } else if (S.showAllExcluded && hidden.length > 6) {
850
+ const less = el('button', 'btn sm', 'Show fewer');
851
+ less.type = 'button';
852
+ less.onclick = () => { S.showAllExcluded = false; renderExcluded(); };
853
+ box.appendChild(less);
854
+ }
855
+ }
856
+
857
+ // re-including one page: drop the rules that hit it, and if an `only` list is
858
+ // in force, add the page to it
859
+ function includeBack(id, defer) {
860
+ const v = view();
861
+ v.exclude = (v.exclude || []).filter((p) => !matches(id, p));
862
+ if (v.include?.length && !v.include.some((p) => matches(id, p))) v.include.push(id);
863
+ if (!defer) saveViews();
864
+ }
865
+
866
+ function fillList(sel, rows, emptyMsg) {
867
+ const box = $(sel); box.textContent = '';
868
+ if (!rows.length) { box.appendChild(el('div', 'empty-note', emptyMsg)); return; }
869
+ for (const r of rows) {
870
+ const d = el('div', 'row' + (r.warn ? ' warn' : ''));
871
+ d.appendChild(el('span', null, r.label));
872
+ d.appendChild(el('span', 'k', r.sub));
873
+ if (r.go && S.pos.has(r.go)) { d.dataset.go = r.go; d.onclick = () => select(r.go); }
874
+ box.appendChild(d);
875
+ }
876
+ }
877
+
878
+ /* ---------- inspector ---------- */
879
+ function select(id, keepScroll) {
880
+ S.sel = id;
881
+ const n = nodeOf(id);
882
+ const ins = $('#inspect');
883
+ if (!n || !S.pos.has(id)) { ins.hidden = true; S.sel = null; applyFilterDim(); return; }
884
+ ins.hidden = false;
885
+ const grouped = isGroup(n);
886
+ $('#insTitle').textContent = grouped ? `${clean(n.id)} section` : (n.title || n.label || id);
887
+ $('#insRoute').textContent = grouped ? `${n.members.length} pages collapsed` : id;
888
+ $('#insFile').textContent = grouped ? '' : (n.file || '');
889
+ $('#openTab').href = grouped ? '/site/' + (n.label === 'root' ? '' : n.label + '/') : '/site' + id;
890
+ $('.devices').hidden = grouped;
891
+ $('.preview-wrap').hidden = grouped;
892
+ $('.viewctl').hidden = grouped;
893
+ if (grouped) renderGroupMembers(n);
894
+ else { $('#members')?.remove(); setPreview(id, Number(document.querySelector('.devices .on')?.dataset.w || 1280)); }
895
+
896
+ // the inspector is an editing surface, so it lists every link on the page,
897
+ // including the site-wide ones folded out of the drawing
898
+ const io = S.ioAll.get(id) || { in: [], out: [] };
899
+ $('#exitCount').textContent = io.out.length;
900
+ $('#entCount').textContent = io.in.length;
901
+
902
+ const exits = $('#exits'); exits.textContent = '';
903
+ if (!io.out.length) exits.appendChild(el('div', 'empty-note', 'Dead end. Nothing on this page navigates anywhere.'));
904
+ for (const e of io.out) exits.appendChild(exitRow(e));
905
+ for (const r of pending('remove-link').filter((r) => r.from === id)) {
906
+ const row = el('div', 'row gone');
907
+ row.appendChild(el('span', null, `${r.label} → ${r.to}`));
908
+ row.appendChild(el('span', 'k', 'queued delete'));
909
+ exits.appendChild(row);
910
+ }
911
+
912
+ const ent = $('#entrances'); ent.textContent = '';
913
+ if (!io.in.length) ent.appendChild(el('div', 'empty-note', 'Orphan. Nothing links here, so it is reachable only by typing the URL.'));
914
+ for (const e of io.in) {
915
+ const row = el('div', 'row');
916
+ row.dataset.go = e.from;
917
+ row.onclick = () => select(e.from);
918
+ row.appendChild(el('span', null, clean(e.from)));
919
+ row.appendChild(el('span', 'k', `“${trunc(e.label, 18)}” · ${e.via}${e.line ? ':' + e.line : ''}`));
920
+ ent.appendChild(row);
921
+ }
922
+
923
+ $('#note').value = (S.flow.notes || {})[id] || '';
924
+ applyFilterDim();
925
+ if (!keepScroll) centerOn(id);
926
+ }
927
+
928
+ function renderGroupMembers(n) {
929
+ $('#members')?.remove();
930
+ const box = el('div', 'rows'); box.id = 'members';
931
+ const btn = el('button', 'btn sm', 'Expand section');
932
+ btn.type = 'button';
933
+ btn.onclick = () => toggleSection(n.group);
934
+ box.appendChild(btn);
935
+ for (const m of n.members) {
936
+ const row = el('div', 'row');
937
+ row.dataset.go = m.id;
938
+ row.appendChild(el('span', null, m.id));
939
+ row.appendChild(el('span', 'k', m.title || ''));
940
+ row.onclick = () => { toggleSection(n.group); setTimeout(() => select(m.id), 60); };
941
+ box.appendChild(row);
942
+ }
943
+ $('.preview-wrap').after(box);
944
+ }
945
+
946
+ function exitRow(e) {
947
+ if (e.folded || String(e.from).startsWith('section:') || String(e.to).startsWith('section:')) {
948
+ const row = el('div', 'row');
949
+ row.appendChild(el('span', null, `${e.label} → ${clean(e.to)}`));
950
+ row.appendChild(el('span', 'k', 'expand to edit'));
951
+ row.title = (e.details || []).map((x) => `“${x.label}” ${x.from} → ${x.to}`).join('\n');
952
+ row.addEventListener('mouseenter', () => hotWire(`${e.from}>${e.to}`, true));
953
+ row.addEventListener('mouseleave', () => hotWire(`${e.from}>${e.to}`, false));
954
+ return row;
955
+ }
956
+ const row = el('div', 'row' + (e.status === 'proposed' ? ' pending' : ''));
957
+ const label = el('input');
958
+ label.value = e.label;
959
+ label.title = 'Button or link text';
960
+ label.setAttribute('aria-label', `Text of the link to ${e.to}`);
961
+ const sel = el('select');
962
+ sel.setAttribute('aria-label', `Destination of “${e.label}”`);
963
+ for (const n of [...S.nodes.values()].sort((a, b) => a.id.localeCompare(b.id))) {
964
+ const o = el('option', null, n.id); o.value = n.id; if (n.id === e.to) o.selected = true; sel.appendChild(o);
965
+ }
966
+ const k = el('span', 'k', e.status === 'proposed' ? 'queued' : `${e.via}${e.line ? ':' + e.line : ''}`);
967
+ const x = iconBtn('i-close', 'x', `Remove “${e.label}”`);
968
+ label.onchange = () => queue({ op: 'relabel', from: e.from, to: e.to, oldLabel: e.label, label: label.value, file: e.file, line: e.line, summary: `Rename “${e.label}” to “${label.value}” on ${e.from}` });
969
+ sel.onchange = () => queue({ op: 'retarget', from: e.from, to: e.to, newTo: sel.value, label: e.label, file: e.file, line: e.line, summary: `“${e.label}” on ${e.from}: ${e.to} becomes ${sel.value}` });
970
+ x.onclick = () => {
971
+ if (e.status === 'proposed') return unqueueAddLink(e);
972
+ queue({ op: 'remove-link', from: e.from, to: e.to, label: e.label, file: e.file, line: e.line, summary: `Remove “${e.label}” (${e.from} to ${e.to})` });
973
+ };
974
+ row.addEventListener('mouseenter', () => hotWire(`${e.from}>${e.to}`, true));
975
+ row.addEventListener('mouseleave', () => hotWire(`${e.from}>${e.to}`, false));
976
+ row.append(label, sel, k, x);
977
+ return row;
978
+ }
979
+
980
+ function setPreview(id, w) {
981
+ const f = $('#preview'), wrap = $('.preview-wrap');
982
+ f.style.width = w + 'px';
983
+ const scale = (wrap.clientWidth || 380) / w;
984
+ f.style.transform = `scale(${scale})`;
985
+ f.style.height = Math.ceil(236 / scale) + 'px';
986
+ const src = '/site' + id;
987
+ const key = src + w + sandbox();
988
+ if (f.dataset.src !== key) { f.setAttribute('sandbox', sandbox()); f.src = src; f.dataset.src = key; }
989
+ }
990
+
991
+ /* ---------- change queue ---------- */
992
+ async function queue(edit) {
993
+ await fetch('/api/edits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(edit) });
994
+ toast(`Queued: ${edit.summary}`);
995
+ await reload();
996
+ }
997
+ async function unqueue(id) { await fetch('/api/edits?id=' + encodeURIComponent(id), { method: 'DELETE' }); await reload(); }
998
+ function unqueueAddLink(e) {
999
+ const m = pending('add-link').find((p) => p.from === e.from && p.to === e.to && (p.label || 'new') === e.label);
1000
+ if (m) unqueue(m.id);
1001
+ }
1002
+
1003
+ function renderEdits() {
1004
+ const list = pending();
1005
+ $('#editCount').textContent = String(list.length);
1006
+ const box = $('#editList'); box.textContent = '';
1007
+ if (!list.length) {
1008
+ box.appendChild(el('div', 'empty-note', 'Nothing queued yet. Rename an exit, change where it points, add a link between two pages, or add a page. Nothing is written to your site until you ask an agent to apply it.'));
1009
+ return;
1010
+ }
1011
+ for (const e of list) {
1012
+ const row = el('div', 'row pending');
1013
+ row.appendChild(el('span', 'op', e.op));
1014
+ row.appendChild(el('span', 'grow', e.summary || JSON.stringify(e)));
1015
+ const x = iconBtn('i-close', 'x', 'Discard this change');
1016
+ x.onclick = () => unqueue(e.id);
1017
+ row.appendChild(x);
1018
+ box.appendChild(row);
1019
+ }
1020
+ }
1021
+
1022
+ /* ---------- hover tooltip ---------- */
1023
+ function showTip(text, ev) {
1024
+ const t = $('#tip');
1025
+ t.hidden = false;
1026
+ t.textContent = text;
1027
+ moveTip(ev);
1028
+ }
1029
+ function moveTip(ev) {
1030
+ const t = $('#tip');
1031
+ if (t.hidden) return;
1032
+ const r = t.getBoundingClientRect();
1033
+ t.style.left = Math.min(ev.clientX + 14, innerWidth - r.width - 12) + 'px';
1034
+ t.style.top = Math.min(ev.clientY + 16, innerHeight - r.height - 12) + 'px';
1035
+ }
1036
+ const hideTip = () => { $('#tip').hidden = true; };
1037
+
1038
+ function hotWire(key, on) {
1039
+ for (const w of $('#wires').querySelectorAll(`[data-wire="${CSS.escape(key)}"]`)) w.classList.toggle('trace', on);
1040
+ $('#wires').classList.toggle('tracing', on);
1041
+ }
1042
+
1043
+ /* ---------- theme ---------- */
1044
+ function applyTheme(mode) {
1045
+ const root = document.documentElement;
1046
+ if (mode) root.dataset.theme = mode; else delete root.dataset.theme;
1047
+ const dark = mode ? mode === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
1048
+ $('#themeIcon').firstElementChild.setAttribute('href', dark ? '#i-sun' : '#i-moon');
1049
+ $('#btnTheme').title = dark ? 'Switch to light' : 'Switch to dark';
1050
+ $('#btnTheme').setAttribute('aria-label', $('#btnTheme').title);
1051
+ }
1052
+
1053
+ function toggleTheme() {
1054
+ const dark = document.documentElement.dataset.theme
1055
+ ? document.documentElement.dataset.theme === 'dark'
1056
+ : matchMedia('(prefers-color-scheme: dark)').matches;
1057
+ const next = dark ? 'light' : 'dark';
1058
+ try { localStorage.setItem(THEME_KEY, next); } catch { /* private mode */ }
1059
+ applyTheme(next);
1060
+ drawWires(); // wire strokes read from CSS variables that just changed
1061
+ }
1062
+
1063
+ /* ---------- interactions ---------- */
1064
+ function wireUI() {
1065
+ let saved = null;
1066
+ try { saved = localStorage.getItem(THEME_KEY); } catch { /* private mode */ }
1067
+ applyTheme(saved === 'light' || saved === 'dark' ? saved : null);
1068
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
1069
+ if (!document.documentElement.dataset.theme) applyTheme(null);
1070
+ });
1071
+ $('#btnTheme').onclick = toggleTheme;
1072
+
1073
+ const stage = $('#stage');
1074
+
1075
+ let panning = null;
1076
+ stage.addEventListener('pointerdown', (ev) => {
1077
+ const card = ev.target.closest('.node');
1078
+ if (card && !ev.target.closest('.port,button')) return startDrag(ev, card);
1079
+ if (card) return;
1080
+ panning = { x: ev.clientX, y: ev.clientY, vx: S.cam.x, vy: S.cam.y };
1081
+ stage.classList.add('panning'); stage.setPointerCapture(ev.pointerId);
1082
+ });
1083
+ stage.addEventListener('pointermove', (ev) => {
1084
+ if (!panning) return;
1085
+ S.cam.x = panning.vx + (ev.clientX - panning.x);
1086
+ S.cam.y = panning.vy + (ev.clientY - panning.y);
1087
+ applyView();
1088
+ });
1089
+ stage.addEventListener('pointerup', () => { panning = null; stage.classList.remove('panning'); });
1090
+ stage.addEventListener('wheel', (ev) => {
1091
+ ev.preventDefault();
1092
+ const k = Math.min(2, Math.max(0.15, S.cam.k * (ev.deltaY < 0 ? 1.12 : 0.89)));
1093
+ const r = stage.getBoundingClientRect();
1094
+ const mx = ev.clientX - r.left, my = ev.clientY - r.top;
1095
+ S.cam.x = mx - (mx - S.cam.x) * (k / S.cam.k);
1096
+ S.cam.y = my - (my - S.cam.y) * (k / S.cam.k);
1097
+ S.cam.k = k; applyView();
1098
+ }, { passive: false });
1099
+
1100
+ // hover a wire or a port -> tooltip + trace
1101
+ stage.addEventListener('mouseover', (ev) => {
1102
+ const t = ev.target.closest('.hit,.port');
1103
+ if (!t || !t.dataset.tip) return;
1104
+ showTip(t.dataset.tip, ev);
1105
+ if (t.dataset.wire) hotWire(t.dataset.wire, true);
1106
+ });
1107
+ stage.addEventListener('mousemove', (ev) => moveTip(ev));
1108
+ stage.addEventListener('mouseout', (ev) => {
1109
+ const t = ev.target.closest('.hit,.port');
1110
+ if (!t) return;
1111
+ hideTip();
1112
+ if (t.dataset.wire) hotWire(t.dataset.wire, false);
1113
+ });
1114
+
1115
+ stage.addEventListener('click', (ev) => {
1116
+ const port = ev.target.closest('.port');
1117
+ if (port && port.dataset.go) { hideTip(); return select(port.dataset.go); }
1118
+ const hit = ev.target.closest('.hit');
1119
+ if (hit) return select(hit.dataset.from, true);
1120
+ const card = ev.target.closest('.node');
1121
+ if (!card) return;
1122
+ if (S.linkFrom) return finishLink(card.dataset.id);
1123
+ select(card.dataset.id, true);
1124
+ });
1125
+ stage.addEventListener('keydown', (ev) => {
1126
+ const card = ev.target.closest?.('.node');
1127
+ if (!card || (ev.key !== 'Enter' && ev.key !== ' ')) return;
1128
+ ev.preventDefault();
1129
+ if (S.linkFrom) return finishLink(card.dataset.id);
1130
+ select(card.dataset.id, true);
1131
+ });
1132
+
1133
+ $('#tabs').onclick = (ev) => {
1134
+ const b = ev.target.closest('.tab'); if (!b) return;
1135
+ if (b.dataset.add) return promptNewView();
1136
+ S.mode = b.dataset.view;
1137
+ S.sel = null; $('#inspect').hidden = true;
1138
+ renderTabs(); recompute(); render(); renderRail(); fit();
1139
+ S.cfg.active = S.mode;
1140
+ fetch('/api/views', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(S.cfg) });
1141
+ };
1142
+
1143
+ const addRule = (kind) => {
1144
+ const pat = $('#ruleInput').value.trim();
1145
+ if (!pat) return toast('Type a route pattern first, for example /docs/**');
1146
+ $('#ruleInput').value = '';
1147
+ addExcludeRule(pat, kind);
1148
+ };
1149
+ $('#ruleAdd').onclick = () => addRule('exclude');
1150
+ $('#ruleAddIn').onclick = () => addRule('include');
1151
+ $('#ruleInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addRule('exclude'); } });
1152
+ $('#viewDelete').onclick = () => deleteView();
1153
+
1154
+ $('#exPage').onclick = () => {
1155
+ if (!S.sel) return;
1156
+ const pat = S.sel;
1157
+ S.sel = null; $('#inspect').hidden = true;
1158
+ addExcludeRule(pat);
1159
+ };
1160
+ $('#exGroup').onclick = () => {
1161
+ if (!S.sel) return;
1162
+ const seg = S.sel.split('/').filter(Boolean)[0];
1163
+ if (!seg) return toast('The root page is not inside a directory.');
1164
+ S.sel = null; $('#inspect').hidden = true;
1165
+ addExcludeRule(`/${seg}/**`);
1166
+ };
1167
+
1168
+ $('.zoom').onclick = (ev) => {
1169
+ const b = ev.target.closest('[data-z]'); if (!b) return;
1170
+ const z = b.dataset.z;
1171
+ if (z === '0') return fit();
1172
+ S.cam.k = Math.min(2, Math.max(0.15, S.cam.k * (z === '+' ? 1.2 : 0.83))); applyView();
1173
+ };
1174
+
1175
+ $('#search').addEventListener('input', (e) => { S.q = e.target.value; applyFilterDim(); });
1176
+
1177
+ const layers = $('#layers'), layersBtn = $('#btnLayers');
1178
+ const closeLayers = () => { layers.hidden = true; layersBtn.setAttribute('aria-expanded', 'false'); };
1179
+ layersBtn.onclick = (ev) => {
1180
+ ev.stopPropagation();
1181
+ layers.hidden = !layers.hidden;
1182
+ layersBtn.setAttribute('aria-expanded', String(!layers.hidden));
1183
+ };
1184
+ document.addEventListener('click', (ev) => { if (!ev.target.closest('.pop-wrap')) closeLayers(); });
1185
+ for (const [id, key] of [['fShared', 'shared'], ['fApi', 'api'], ['fExt', 'ext'], ['fPreview', 'preview'], ['fJs', 'js']]) {
1186
+ $('#' + id).addEventListener('change', (e) => {
1187
+ S.filters[key] = e.target.checked;
1188
+ if (key === 'js' && e.target.checked) toast('Page JavaScript is on. Heavy pages can stall the map, so turn it back off if this gets slow.');
1189
+ recompute(); render(); renderRail(); renderLayerCount();
1190
+ if (S.sel) select(S.sel, true);
1191
+ });
1192
+ }
1193
+ renderLayerCount();
1194
+
1195
+ $('#btnRescan').onclick = async () => {
1196
+ const b = $('#btnRescan');
1197
+ b.disabled = true;
1198
+ toast('Rescanning…', true);
1199
+ try {
1200
+ const r = await fetch('/api/rescan', { method: 'POST' }).then((x) => x.json());
1201
+ await reload();
1202
+ toast(r.ok ? 'Rescanned' : 'Rescan failed. Check the terminal running sitelines.');
1203
+ } catch {
1204
+ toast('Rescan failed. Is sitelines still running?');
1205
+ } finally {
1206
+ b.disabled = false;
1207
+ }
1208
+ };
1209
+
1210
+ const drawer = $('#drawer');
1211
+ const setDrawer = (open) => { drawer.hidden = !open; $('#btnEdits').setAttribute('aria-expanded', String(open)); if (open) renderEdits(); };
1212
+ $('#btnEdits').onclick = () => setDrawer(drawer.hidden);
1213
+ $('#drawerClose').onclick = () => setDrawer(false);
1214
+ $('#copyPrompt').onclick = async () => {
1215
+ const list = pending();
1216
+ if (!list.length) return toast('Nothing queued to copy.');
1217
+ const txt = 'Apply my sitelines changes (.sitelines/edits.json):\n' + list.map((e) => `- [${e.op}] ${e.summary}`).join('\n');
1218
+ try { await navigator.clipboard.writeText(txt); toast('Prompt copied'); }
1219
+ catch { toast('Could not reach the clipboard. Open .sitelines/edits.json instead.'); }
1220
+ };
1221
+
1222
+ $('#insClose').onclick = () => { $('#inspect').hidden = true; S.sel = null; applyFilterDim(); };
1223
+ $('#addExit').onclick = () => startLink(S.sel);
1224
+ $('#delPage').onclick = async () => {
1225
+ const n = S.nodes.get(S.sel); if (!n) return;
1226
+ const v = await modal('Delete page', [{ name: 'confirm', label: `Type ${n.id} to confirm. This queues a change; nothing is deleted until an agent applies it.`, value: '' }], 'Queue deletion');
1227
+ if (!v || v.confirm.trim() !== n.id) return toast('Cancelled');
1228
+ queue({ op: 'delete-page', route: n.id, file: n.file, summary: `Delete page ${n.id} (${n.file || 'no file'}) and every link to it` });
1229
+ };
1230
+ $('#btnAdd').onclick = async () => {
1231
+ const v = await modal('New page', [
1232
+ { name: 'route', label: 'Route, for example /pricing/', value: '/' },
1233
+ { name: 'label', label: 'Page title', value: '' },
1234
+ { name: 'from', label: 'Linked from (optional route)', value: S.sel || '' },
1235
+ { name: 'trigger', label: 'Button text on that page', value: '' },
1236
+ ], 'Queue page');
1237
+ if (!v || !v.route) return;
1238
+ await queue({ op: 'add-page', route: v.route, label: v.label, summary: `New page ${v.route}${v.label ? ` - “${v.label}”` : ''}` });
1239
+ if (v.from) await queue({ op: 'add-link', from: v.from, to: v.route, label: v.trigger || v.label || v.route, via: 'link', summary: `Add “${v.trigger || v.label}” on ${v.from} to ${v.route}` });
1240
+ };
1241
+ $('#note').addEventListener('change', async (e) => {
1242
+ const notes = { ...(S.flow.notes || {}), [S.sel]: e.target.value };
1243
+ await fetch('/api/layout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ notes }) });
1244
+ S.flow.notes = notes;
1245
+ });
1246
+ $('.devices').onclick = (ev) => {
1247
+ const b = ev.target.closest('[data-w]'); if (!b || !b.dataset.w) return;
1248
+ for (const x of document.querySelectorAll('.devices .btn')) x.classList.remove('on');
1249
+ b.classList.add('on'); setPreview(S.sel, Number(b.dataset.w));
1250
+ };
1251
+
1252
+ document.addEventListener('keydown', (ev) => {
1253
+ if (ev.target.matches('input,textarea,select')) { if (ev.key === 'Escape') ev.target.blur(); return; }
1254
+ if (ev.key === '/') { ev.preventDefault(); $('#search').focus(); }
1255
+ if (ev.key === 'Escape') {
1256
+ S.linkFrom = null; $('#stage').classList.remove('linking');
1257
+ if (!$('#modal').hidden) $('#modalCancel').click();
1258
+ setDrawer(false); closeLayers(); hideTip(); toast('');
1259
+ }
1260
+ if (ev.key === 'e' && S.sel) startLink(S.sel);
1261
+ if (ev.key === 'f') fit();
1262
+ if (['1', '2', '3', '4', '5'].includes(ev.key)) document.querySelectorAll('.tab')[Number(ev.key) - 1]?.click();
1263
+ });
1264
+ }
1265
+
1266
+ function startDrag(ev, card) {
1267
+ const id = card.dataset.id, p = S.pos.get(id);
1268
+ const start = { x: ev.clientX, y: ev.clientY, px: p.x, py: p.y };
1269
+ let moved = false;
1270
+ card.setPointerCapture(ev.pointerId);
1271
+ const move = (e) => {
1272
+ const dx = (e.clientX - start.x) / S.cam.k, dy = (e.clientY - start.y) / S.cam.k;
1273
+ if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
1274
+ p.x = start.px + dx; p.y = start.py + dy;
1275
+ card.style.left = p.x + 'px'; card.style.top = p.y + 'px';
1276
+ queueWires();
1277
+ };
1278
+ const up = () => {
1279
+ card.removeEventListener('pointermove', move);
1280
+ card.removeEventListener('pointerup', up);
1281
+ if (!moved) return;
1282
+ const layout_ = { [id]: { x: Math.round(p.x), y: Math.round(p.y) } };
1283
+ S.flow.layout = { ...S.flow.layout, ...layout_ };
1284
+ fetch('/api/layout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ layout: layout_ }) });
1285
+ };
1286
+ card.addEventListener('pointermove', move);
1287
+ card.addEventListener('pointerup', up);
1288
+ }
1289
+
1290
+ function startLink(from) {
1291
+ if (!from) return;
1292
+ S.linkFrom = from;
1293
+ $('#stage').classList.add('linking');
1294
+ toast(`Click the destination page for a new link from ${from}. Escape cancels.`, true);
1295
+ }
1296
+
1297
+ async function finishLink(to) {
1298
+ const from = S.linkFrom;
1299
+ S.linkFrom = null; $('#stage').classList.remove('linking');
1300
+ toast('');
1301
+ if (!from || from === to) return;
1302
+ const v = await modal('New link', [
1303
+ { name: 'label', label: `Button or link text on ${from}`, value: '' },
1304
+ { name: 'via', label: 'Kind', type: 'select', options: ['link', 'button', 'redirect', 'form'], value: 'link' },
1305
+ { name: 'where', label: 'Where on the page (optional)', value: '' },
1306
+ ], 'Queue link');
1307
+ if (!v) return;
1308
+ queue({ op: 'add-link', from, to, label: v.label || to, via: v.via, where: v.where, summary: `Add ${v.via} “${v.label || to}” on ${from} to ${to}${v.where ? ` (${v.where})` : ''}` });
1309
+ }
1310
+
1311
+ /* ---------- misc ui ---------- */
1312
+ function modal(title, fields, okLabel) {
1313
+ return new Promise((resolve) => {
1314
+ const m = $('#modal'), body = $('#modalBody');
1315
+ $('#modalTitle').textContent = title;
1316
+ $('#modalOk').textContent = okLabel || 'Confirm';
1317
+ body.textContent = ''; m.hidden = false;
1318
+ const inputs = {};
1319
+ for (const f of fields) {
1320
+ const l = el('label', null, f.label);
1321
+ let inp;
1322
+ if (f.type === 'select') { inp = el('select'); for (const o of f.options) { const op = el('option', null, o); op.value = o; inp.appendChild(op); } }
1323
+ else inp = el('input');
1324
+ inp.value = f.value ?? '';
1325
+ l.appendChild(inp); body.appendChild(l); inputs[f.name] = inp;
1326
+ }
1327
+ const first = body.querySelector('input,select');
1328
+ first?.focus();
1329
+ first?.select?.();
1330
+ const done = (v) => { m.hidden = true; $('#modalForm').onsubmit = null; resolve(v); };
1331
+ $('#modalForm').onsubmit = (e) => { e.preventDefault(); done(Object.fromEntries(Object.entries(inputs).map(([k, i]) => [k, i.value]))); };
1332
+ $('#modalCancel').onclick = () => done(null);
1333
+ });
1334
+ }
1335
+
1336
+ function toast(msg, sticky) {
1337
+ const h = $('#toast');
1338
+ h.textContent = msg; h.classList.toggle('show', !!msg);
1339
+ clearTimeout(toast.t);
1340
+ if (msg && !sticky) toast.t = setTimeout(() => h.classList.remove('show'), 3200);
1341
+ }
1342
+
1343
+ function centerOn(id) {
1344
+ const p = S.pos.get(id); if (!p) return;
1345
+ const r = $('#stage').getBoundingClientRect();
1346
+ S.cam.x = r.width / 2 - (p.x + p.w / 2) * S.cam.k;
1347
+ S.cam.y = r.height / 2 - (p.y + p.h / 2) * S.cam.k;
1348
+ applyView();
1349
+ }
1350
+
1351
+ function fit() {
1352
+ const xs = [...S.pos.values()];
1353
+ if (!xs.length) return;
1354
+ const maxX = Math.max(...xs.map((p) => p.x + p.w)), maxY = Math.max(...xs.map((p) => p.y + p.h));
1355
+ const r = $('#stage').getBoundingClientRect();
1356
+ S.cam.k = Math.min(1, Math.max(0.3, Math.min((r.width - 40) / (maxX + 40), (r.height - 40) / (maxY + 40))));
1357
+ S.cam.x = 20; S.cam.y = 30 * S.cam.k;
1358
+ applyView();
1359
+ }
1360
+
1361
+ const trunc = (s, n) => (String(s || '').length > n ? String(s).slice(0, n - 1) + '…' : String(s || ''));
1362
+
1363
+ // the scan root is stored relative to wherever the scan ran, which can be a
1364
+ // ../../.. chain that tells the reader nothing. Show the tail that does.
1365
+ function shortRoot(root) {
1366
+ if (!root || root === '.') return '';
1367
+ const seg = String(root).split('/').filter((s) => s && s !== '.' && s !== '..');
1368
+ if (!seg.length) return '';
1369
+ return (seg.length > 2 ? '…/' : '') + seg.slice(-2).join('/') + '/';
1370
+ }
1371
+ // "section:admin" reads as "/admin/" everywhere it is shown to a human
1372
+ const clean = (id) => String(id).startsWith('section:')
1373
+ ? `/${String(id).slice(8) === 'root' ? '' : String(id).slice(8) + '/'}` : String(id);