@giamme/forge 0.8.0 → 0.9.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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +3 -2
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +69 -0
  8. package/docs/commands.md +315 -0
  9. package/docs/dataset.md +20 -0
  10. package/docs/decomposed-runs.md +268 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +92 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +231 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +5 -0
  21. package/package.json +3 -2
  22. package/references/decompose.md +1 -1
  23. package/references/fractal.md +1 -1
  24. package/references/harnesses.md +18 -6
  25. package/references/solo.md +1 -1
  26. package/scripts/forge-dispatch.sh +7 -1
  27. package/scripts/forge-runs.py +121 -0
  28. package/scripts/forge-runtime.py +59 -6
  29. package/scripts/forge-version.py +54 -12
  30. package/scripts/forge_dataset/harvest.py +1 -1
  31. package/scripts/forge_dataset/importer.py +1 -1
  32. package/scripts/forge_fractal/__init__.py +2 -1
  33. package/ui/README.md +80 -20
  34. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  35. package/ui/docs/api.md +99 -17
  36. package/ui/forge_ui/export.py +16 -6
  37. package/ui/forge_ui/fractal.py +1 -2
  38. package/ui/forge_ui/reader.py +95 -7
  39. package/ui/forge_ui/server.py +10 -1
  40. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  41. package/ui/forge_ui/static/app.js +279 -124
  42. package/ui/forge_ui/static/cloud3d.js +3 -2
  43. package/ui/forge_ui/static/cloudui.js +8 -5
  44. package/ui/forge_ui/static/convo.js +295 -0
  45. package/ui/forge_ui/static/datatable.js +232 -0
  46. package/ui/forge_ui/static/diffview.js +29 -10
  47. package/ui/forge_ui/static/findings3d.js +134 -0
  48. package/ui/forge_ui/static/graph.js +3 -3
  49. package/ui/forge_ui/static/icons.js +94 -0
  50. package/ui/forge_ui/static/index.html +25 -13
  51. package/ui/forge_ui/static/iso.js +70 -19
  52. package/ui/forge_ui/static/leaderboard.js +44 -48
  53. package/ui/forge_ui/static/panel.js +68 -32
  54. package/ui/forge_ui/static/runmodel.js +17 -1
  55. package/ui/forge_ui/static/scene3d.js +518 -43
  56. package/ui/forge_ui/static/search.js +2 -1
  57. package/ui/forge_ui/static/shell.js +137 -0
  58. package/ui/forge_ui/static/stagectl.js +150 -0
  59. package/ui/forge_ui/static/style.css +924 -469
  60. package/ui/forge_ui/static/theme.js +25 -0
  61. package/ui/forge_ui/transcripts.py +608 -0
@@ -1,12 +1,11 @@
1
1
  /* forge-ui diff viewer: a task's diff (current, previous, committed, or exactly what one reviewer read), a final
2
2
  review's combined diff or a Fractal node's candidate, with each review finding shown under the line it points
3
3
  at. Defines window.ForgeDiff.render(host, ctx). All API values enter the DOM as text.
4
- ctx = { run, spec: {kind: 'task'|'final'|'fractal', task, source, file, name, node}, fetchJSON(path),
4
+ ctx = { run, spec: {kind: 'task'|'final'|'fractal', task, source, file, name, node, finding}, fetchJSON(path),
5
5
  rerender(), href(spec) → hash, closeHref }. */
6
6
  window.ForgeDiff = (() => {
7
7
  'use strict';
8
8
  const list = v => Array.isArray(v) ? v : [];
9
- const SEV = { P0: 'fail', P1: 'conflict', P2: 'timeout', P3: 'unknown' };
10
9
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
11
10
  const BUDGET = 4000; // lines drawn before the rest of the files wait for a click
12
11
  const cache = new Map(); // url → {data, error, pending, fresh}
@@ -18,6 +17,7 @@ window.ForgeDiff = (() => {
18
17
  return n;
19
18
  }
20
19
  const chip = (text, st) => el('span', text, 'chip st-' + st);
20
+ const sevChip = (text, sev) => el('span', text, 'chip sev-chip sev-' + (/^P[0-3]$/.test(sev) ? sev : 'none'));
21
21
  const enc = encodeURIComponent;
22
22
  const TASK_FILES = new Set(['current', 'prev', 'committed', 'existing']);
23
23
  function urlFor(run, spec) {
@@ -47,8 +47,9 @@ window.ForgeDiff = (() => {
47
47
  /* ---------- one finding, as a row under its line or in a list ---------- */
48
48
  function findingBox(f, where) {
49
49
  const box = el('div', undefined, 'diff-finding sev-' + (f.severity || 'P3'));
50
+ if (f.id) box.dataset.finding = f.id; // a mark on the stage opens the diff at this finding
50
51
  const head = el('div', undefined, 'diff-finding-head');
51
- head.append(chip(f.severity || '?', SEV[f.severity] || 'unknown'));
52
+ head.append(sevChip(f.severity || '?', f.severity));
52
53
  if (f.status) head.append(el('span', f.status.toLowerCase(), 'tag'));
53
54
  const r = f.review || {};
54
55
  head.append(el('span', `${r.kind || 'review'}${r.attempt != null ? ' · attempt ' + r.attempt : ''}${r.spec ? ' · ' + r.spec : ''}`, 'muted'));
@@ -66,7 +67,7 @@ window.ForgeDiff = (() => {
66
67
  const st = { modified: 'running', added: 'merged', deleted: 'fail', renamed: 'nochanges', copied: 'nochanges' }[f.status] || 'unknown';
67
68
  head.append(chip(f.status, st));
68
69
  const name = el('a', f.status === 'renamed' || f.status === 'copied' ? `${f.old} → ${f.new}` : pathOf(f), 'mono diff-path');
69
- name.href = ctx.href({ ...ctx.spec, file: pathOf(f) });
70
+ name.href = ctx.href({ ...ctx.spec, finding: null, file: pathOf(f) });
70
71
  head.append(name, el('span', `+${f.additions} −${f.deletions}`, 'diff-stat'));
71
72
  if (f.binary) head.append(el('span', 'binary', 'tag'));
72
73
  if (f.truncated) head.append(el('span', 'cut short', 'tag'));
@@ -118,15 +119,20 @@ window.ForgeDiff = (() => {
118
119
  function render(host, ctx) {
119
120
  const run = ctx.run, spec = ctx.spec, url = urlFor(run, spec), entry = load(ctx, url);
120
121
  // a poll that brought nothing new leaves thousands of rows alone
121
- const sig = [url, entry.at || 0, !!entry.error, spec.file, opened.size].join('|');
122
+ const sig = [url, entry.at || 0, !!entry.error, spec.file, spec.finding, opened.size].join('|');
122
123
  if (host.diffSig === sig) return;
123
124
  host.diffSig = sig;
125
+ // the finding the keyboard is on keeps it through the rebuild (a poll, a fold)
126
+ const had = document.activeElement && host.contains(document.activeElement) && document.activeElement.closest('[data-finding]');
127
+ const keep = had ? had.dataset.finding : null;
124
128
  host.replaceChildren();
125
129
  const D = entry.data;
126
130
  const top = el('div', undefined, 'diff-top');
127
131
  const what = spec.kind === 'final' ? 'Final review' : spec.kind === 'fractal' ? `${spec.task} · Fractal node ${spec.node}` : spec.task === 'solo' && run.kind === 'solo' ? 'Solo run' : `Task ${spec.task}`;
128
132
  const title = el('h2'); title.append(el('span', 'Diff · ', 'muted'), document.createTextNode(what)); top.append(title);
129
- const close = el('a', 'Close ✕', 'diff-close'); close.href = ctx.closeHref; top.append(close);
133
+ const close = el('a', undefined, 'diff-close button'); close.href = ctx.closeHref;
134
+ if (window.ForgeIcons) close.append(window.ForgeIcons.icon('close'));
135
+ close.append(document.createTextNode('Close')); top.append(close);
130
136
  host.append(top);
131
137
  if (!D) {
132
138
  host.append(el('p', entry.error ? (entry.error.snapshot ? 'This diff is not in this export.' : `Could not load this diff (${entry.error.status === 404 ? 'not found' : entry.error.message}).`) : 'Loading the diff…', entry.error ? 'warn' : 'muted'));
@@ -140,7 +146,7 @@ window.ForgeDiff = (() => {
140
146
  const cur = spec.kind === 'final' ? spec.name : spec.kind === 'fractal' ? 'candidate' : spec.source || 'current';
141
147
  for (const s of sources) {
142
148
  const a = el('a', s.label || s.id, 'diff-source'); a.setAttribute('aria-current', String(s.id === cur));
143
- a.href = spec.kind === 'final' ? ctx.href({ kind: 'final', name: s.id }) : ctx.href({ ...spec, source: s.id, file: null });
149
+ a.href = spec.kind === 'final' ? ctx.href({ kind: 'final', name: s.id }) : ctx.href({ ...spec, source: s.id, file: null, finding: null });
144
150
  if (s.verdict) a.append(document.createTextNode(' '), chip(s.verdict, String(s.verdict).toLowerCase()));
145
151
  a.title = `${s.bytes != null ? s.bytes + ' bytes' : ''}${s.kind ? ' · ' + s.kind : ''}`;
146
152
  bar.append(a);
@@ -166,13 +172,13 @@ window.ForgeDiff = (() => {
166
172
  const nav = el('nav', undefined, 'diff-files'); nav.setAttribute('aria-label', 'Files in this diff');
167
173
  const ul = el('ul');
168
174
  files.forEach((f, fi) => {
169
- const li = el('li'), a = el('a', undefined, 'diff-file-link'); a.href = ctx.href({ ...spec, file: pathOf(f) }); a.dataset.fi = String(fi);
175
+ const li = el('li'), a = el('a', undefined, 'diff-file-link'); a.href = ctx.href({ ...spec, finding: null, file: pathOf(f) }); a.dataset.fi = String(fi);
170
176
  const p = pathOf(f), cut = p.lastIndexOf('/');
171
177
  a.append(el('span', cut >= 0 ? p.slice(0, cut + 1) : '', 'diff-dir'), el('span', cut >= 0 ? p.slice(cut + 1) : p, 'diff-base'));
172
178
  const stat = el('span', undefined, 'diff-stat'); stat.append(el('span', `+${f.additions}`, 'diff-add'), document.createTextNode(' '), el('span', `−${f.deletions}`, 'diff-del'));
173
179
  a.append(stat);
174
180
  const ps = byFile.get(fi) || [];
175
- if (ps.length) { const w = worst(ps.map(x => x.f)); a.append(chip(`${ps.length}`, SEV[w] || 'unknown')); a.title = `${ps.length} finding${ps.length === 1 ? '' : 's'}, worst ${w}`; }
181
+ if (ps.length) { const w = worst(ps.map(x => x.f)); a.append(sevChip(`${ps.length}`, w)); a.title = `${ps.length} finding${ps.length === 1 ? '' : 's'}, worst ${w}`; }
176
182
  if (spec.file && spec.file === p) a.setAttribute('aria-current', 'true');
177
183
  li.append(a); ul.append(li);
178
184
  });
@@ -207,7 +213,20 @@ window.ForgeDiff = (() => {
207
213
  fileEls[i].scrollIntoView({ block: 'start' }); e.preventDefault();
208
214
  }
209
215
  };
210
- if (spec.file) {
216
+ const flash = n => { n.classList.add('flash'); setTimeout(() => n.classList.remove('flash'), 1600); };
217
+ const back = keep && order.find(n => n.dataset.finding === keep);
218
+ if (back) back.focus({ preventScroll: true });
219
+ if (spec.finding) {
220
+ const target = order.find(n => n.dataset.finding === spec.finding);
221
+ if (target && host.dataset.scrolledTo !== url + '\n#' + spec.finding) {
222
+ host.dataset.scrolledTo = url + '\n#' + spec.finding; fIdx = order.indexOf(target);
223
+ requestAnimationFrame(() => {
224
+ const live = target.isConnected ? target : [...host.querySelectorAll('.diff-finding[id]')].find(n => n.dataset.finding === spec.finding);
225
+ if (!live) return;
226
+ live.scrollIntoView({ block: 'center' }); flash(live); live.focus({ preventScroll: true });
227
+ });
228
+ }
229
+ } else if (spec.file) {
211
230
  const target = fileEls.find(s => s.dataset.path === spec.file);
212
231
  if (target && host.dataset.scrolledTo !== url + '\n' + spec.file) { host.dataset.scrolledTo = url + '\n' + spec.file; requestAnimationFrame(() => target.scrollIntoView({ block: 'start' })); }
213
232
  }
@@ -0,0 +1,134 @@
1
+ /* forge-ui finding beacons: every review finding as a small mark over the stage. Its shape is its severity
2
+ (P0 spike, P1 diamond, P2 cone, P3 pin, no severity a ring), drawn like the blocks: a hairline in its --sev-*
3
+ colour over a translucent fill of the same, on a short post, its foot on the block it is about; a confirmed
4
+ finding is full size and fuller, a plausible one smaller and paler. Seen from straight above (the top-down
5
+ projection) the marks lie flat, so each keeps its silhouette. scene3d.js says where each one stands (an anchor
6
+ that follows its block as it lifts, opens or travels); this module draws them, one instanced mesh per shape
7
+ with the engine's hairline outline, and picks them. Marks stand still, so an idle stage draws no frames; one
8
+ that arrives (a new finding on a poll, one a replay reaches, one moving onto an opened task) drops in. */
9
+ export const SHAPES = ['P0', 'P1', 'P2', 'P3', 'none'];
10
+ const MAX = 512; // marks per shape
11
+ const SIZE = 18; // plan units per unit of the shapes below: a mark stands ~40 tall
12
+ const MIN_PX = 28; // a mark stands at least this tall on screen while the plate is near
13
+ const FAR_PPU = .5; // screen px per plan unit below which that floor shrinks with the plate, to half
14
+ const TALL = 2.2; // a mark's height in shape units (post and head)
15
+
16
+ export function createBeacons(E, { reduce = false } = {}) {
17
+ const { THREE, W, TS } = E;
18
+ const POST = .3; // the post's height; every head sits on it
19
+ const post = () => new THREE.CylinderGeometry(.035, .035, POST, 6).translate(0, POST / 2, 0);
20
+ const geo = {
21
+ P0: mergeTwo(THREE, new THREE.ConeGeometry(.42, 1.6, 4).translate(0, POST + .8, 0), post()),
22
+ P1: mergeTwo(THREE, new THREE.OctahedronGeometry(.62).scale(1, 1.3, 1).translate(0, POST + .8, 0), post()),
23
+ P2: mergeTwo(THREE, new THREE.ConeGeometry(.62, 1.2, 16).rotateX(Math.PI).translate(0, POST + .6, 0), post()),
24
+ P3: mergeTwo(THREE, new THREE.SphereGeometry(.5, 18, 12).translate(0, POST + .48, 0), post()),
25
+ none: mergeTwo(THREE, new THREE.TorusGeometry(.5, .12, 8, 24).translate(0, POST + .56, 0), post()),
26
+ };
27
+ const group = new THREE.Group(); group.name = 'beacons';
28
+ const meshes = {}, outlines = {};
29
+ for (const k of SHAPES) {
30
+ const m = new THREE.InstancedMesh(geo[k], new THREE.MeshBasicMaterial({ toneMapped: false, transparent: true, depthWrite: false }), MAX);
31
+ m.count = 0; m.frustumCulled = false; m.userData = { beacons: k }; m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
32
+ m.setColorAt(0, new THREE.Color()); // allocates the per-instance colours
33
+ const o = new THREE.InstancedMesh(geo[k], E.outlineInstanced(), MAX);
34
+ o.material.side = THREE.BackSide; o.count = 0; o.frustumCulled = false; o.instanceMatrix = m.instanceMatrix; o.raycast = () => {};
35
+ group.add(m, o); meshes[k] = m; outlines[k] = o;
36
+ }
37
+ let unit = SIZE; // plan units per shape unit this frame (marks grow when far away)
38
+ let items = [], byShape = Object.fromEntries(SHAPES.map(k => [k, []])), seen = null, visible = true, hot = null, flat = false;
39
+ const FLAT = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2); // the head toward the top of the screen
40
+ const M4 = new THREE.Matrix4(), Q = new THREE.Quaternion(), S = new THREE.Vector3(), P = new THREE.Vector3(), C = new THREE.Color();
41
+ const ez = t => 1 - Math.pow(1 - t, 3);
42
+
43
+ // items: [{key, sev: 'P0'..'P3'|null, plausible, anchor() → [x, y, z, scale?] in plan units | null (hidden), data}]
44
+ function set(next) {
45
+ const now = performance.now(), first = seen === null;
46
+ seen = seen || new Set();
47
+ items = next.map(it => ({ ...it, shape: SHAPES.includes(it.sev) ? it.sev : 'none', born: first || seen.has(it.key) || reduce ? -Infinity : now,
48
+ phase: (hash(it.key) % 628) / 100 }));
49
+ for (const it of items) seen.add(it.key);
50
+ byShape = Object.fromEntries(SHAPES.map(k => [k, []]));
51
+ for (const it of items) if (byShape[it.shape].length < MAX) byShape[it.shape].push(it);
52
+ paint();
53
+ }
54
+ // the fill is the severity's colour seen through: fuller when confirmed, paler when plausible; the hairline is the colour itself
55
+ function paint() {
56
+ const bg = E.BG_COL, light = E.theme === 'light';
57
+ for (const k of SHAPES) {
58
+ const m = meshes[k], list = byShape[k];
59
+ list.forEach((it, i) => {
60
+ C.copy(E.sevColor(it.sev || 'none')).lerp(bg, it.plausible ? .7 : .45);
61
+ if (hot === it.key) C.lerp(E.INKHI, .3);
62
+ m.setColorAt(i, C);
63
+ });
64
+ if (m.instanceColor) m.instanceColor.needsUpdate = true;
65
+ m.material.opacity = light ? .82 : .75;
66
+ outlines[k].material.uniforms.color.value.copy(E.sevColor(k));
67
+ }
68
+ }
69
+ // every frame: each mark where its anchor is; one that just arrived falls in. Returns whether anything moves.
70
+ function tick(now) {
71
+ if (!visible) { for (const k of SHAPES) meshes[k].count = outlines[k].count = 0; return false; }
72
+ let moving = false;
73
+ const ppu = Math.max(1e-6, E.pxPerUnit()), least = MIN_PX * Math.min(1, Math.max(.5, ppu / FAR_PPU)) / (ppu * TALL);
74
+ unit = Math.max(SIZE, least);
75
+ for (const k of SHAPES) {
76
+ const m = meshes[k], list = byShape[k];
77
+ let n = 0;
78
+ for (const it of list) {
79
+ const a = it.anchor();
80
+ if (!a) { it.hidden = true; continue; }
81
+ if (it.hidden && !reduce) it.born = now; // shown again: it arrives
82
+ it.hidden = false;
83
+ const age = (now - it.born) / 650, drop = age < 1 ? 1 - ez(Math.max(0, age)) : 0;
84
+ if (drop > 0) moving = true;
85
+ const sc = unit * (a[3] || 1) * (it.plausible ? .8 : 1) * (hot === it.key ? 1.2 : 1);
86
+ const lie = flat ? .7 * sc : 0; // lying flat, it floats its own thickness over the roof
87
+ P.copy(W(a[0], a[1], a[2] + drop * 60 + lie));
88
+ it.head = flat ? [a[0], a[1] - 1.15 * sc, a[2] + lie] : [a[0], a[1], a[2] + drop * 60 + 1.15 * sc]; // where its head is drawn, in plan units
89
+ Q.setFromAxisAngle(UP, it.phase); if (flat) Q.premultiply(FLAT);
90
+ S.setScalar(sc * TS);
91
+ M4.compose(P, Q, S); m.setMatrixAt(n, M4); it.slot = n; n++;
92
+ }
93
+ m.count = outlines[k].count = n; m.instanceMatrix.needsUpdate = true;
94
+ m.boundingSphere = null; // three.js keeps the first one it computes: picking needs today's
95
+ }
96
+ return moving;
97
+ }
98
+ const UP = new THREE.Vector3(0, 1, 0);
99
+ // the item under the pointer, or null
100
+ function pick(cx, cy) {
101
+ if (!visible) return null;
102
+ const h = E.pickHit(cx, cy, SHAPES.map(k => meshes[k]).filter(m => m.count));
103
+ if (!h || h.instanceId == null) return null;
104
+ const k = h.object.userData.beacons;
105
+ return byShape[k].find(it => it.slot === h.instanceId && it.anchor()) || null;
106
+ }
107
+ function setHot(key) { if (key !== hot) { hot = key; paint(); } }
108
+ E.themeHooks.add(() => paint());
109
+ return {
110
+ group, set, tick, pick, setHot, repaint: paint,
111
+ get count() { return items.length; },
112
+ get unit() { return unit; }, // what scene3d spaces marks by
113
+ get items() { return items; },
114
+ setVisible(v) { visible = !!v; group.visible = visible; },
115
+ setFlat(v) { flat = !!v; }, // the top-down projection
116
+ get flat() { return flat; },
117
+ dispose() { for (const k of SHAPES) { meshes[k].dispose(); outlines[k].dispose(); geo[k].dispose(); } },
118
+ };
119
+ }
120
+
121
+ function hash(s) { let h = 7; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h; }
122
+ // two geometries as one (positions and normals), so the pin is one instance; one without an index (the
123
+ // octahedron) draws its vertices in order
124
+ function mergeTwo(THREE, a, b) {
125
+ const out = new THREE.BufferGeometry(), na = a.attributes.position.count, nb = b.attributes.position.count;
126
+ const cat = (x, y, n) => { const r = new Float32Array(x.length + y.length); r.set(x); r.set(y, x.length); return new THREE.BufferAttribute(r, n); };
127
+ out.setAttribute('position', cat(a.attributes.position.array, b.attributes.position.array, 3));
128
+ out.setAttribute('normal', cat(a.attributes.normal.array, b.attributes.normal.array, 3));
129
+ const order = (g, n) => g.index ? Array.from(g.index.array) : Array.from({ length: n }, (_, i) => i);
130
+ const ia = order(a, na), ib = order(b, nb).map(i => i + na);
131
+ out.setIndex(ia.concat(ib));
132
+ a.dispose(); b.dispose();
133
+ return out;
134
+ }
@@ -240,9 +240,9 @@
240
240
 
241
241
  if (t.verdict === 'PASS' || t.verdict === 'FAIL') {
242
242
  var pass = t.verdict === 'PASS';
243
- textEl(g, pass ? '✓' : '✗', {
244
- 'class': 'fg-verdict' + (pass ? ' fg-pass' : ''), x: NODE_W - 8, y: 17, 'text-anchor': 'end', 'font-size': 14, 'font-weight': 'bold'
245
- });
243
+ // a drawn tick or cross, in the verdict's colour
244
+ el('path', { 'class': 'fg-verdict' + (pass ? ' fg-pass' : ''), d: pass ? 'M' + (NODE_W - 20) + ' 12l4 4 7-8' : 'M' + (NODE_W - 19) + ' 8l8 8m0-8l-8 8',
245
+ fill: 'none', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, g);
246
246
  }
247
247
 
248
248
  function select() { if (typeof options.onSelect === 'function') options.onSelect(t.id); }
@@ -0,0 +1,94 @@
1
+ /* forge-ui icons: one hairline set drawn for forge-ui on a 24-unit grid, 1.5 stroke, round joins, currentColor.
2
+ States come from CSS (colour, opacity); an icon never carries its own colour. Built with createElementNS: the page CSP
3
+ refuses markup parsed from strings. Also the model name plate, the one way a model is shown everywhere.
4
+ window.ForgeIcons = { icon(name, title?) → <svg class="icon icon-<name>">, modelPlate(spec) → <span class="model-plate">, names } */
5
+ window.ForgeIcons = (() => {
6
+ 'use strict';
7
+ const NS = 'http://www.w3.org/2000/svg';
8
+ const P = d => ['path', { d }], C = (cx, cy, r) => ['circle', { cx, cy, r }], R = (x, y, width, height, rx = 1.5) => ['rect', { x, y, width, height, rx }];
9
+ const SET = {
10
+ map: [R(3, 4, 18, 16, 2), P('M7 9h4v3H7zM13 9h4v6h-4zM7 14h4v2H7')],
11
+ cloud: [C(12, 12, 2.2), P('M3.5 12c0-2.5 3.8-4.5 8.5-4.5s8.5 2 8.5 4.5-3.8 4.5-8.5 4.5S3.5 14.5 3.5 12Z'), C(18.5, 9.2, 1)],
12
+ models: [P('M5 20V11M10 20V5M15 20v-7M20 20V8M3 20h18')],
13
+ search: [C(10.5, 10.5, 6), P('m15 15 5.5 5.5')],
14
+ bell: [P('M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 1.5h-15Z'), P('M10 20.5a2.2 2.2 0 0 0 4 0')],
15
+ live: [C(12, 12, 2), P('M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14')],
16
+ sun: [C(12, 12, 3.8), P('M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4')],
17
+ moon: [P('M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10Z')],
18
+ system: [R(3, 4.5, 18, 12, 1.5), P('M9 20h6M12 16.5V20')],
19
+ immersive: [R(3, 4, 18, 16, 2), R(5.5, 6.5, 4, 8, 1), R(14.5, 6.5, 4, 11, 1)],
20
+ workbench: [R(3, 4, 18, 16, 2), P('M8 4v16M16 4v16M8 15h8')],
21
+ 'panel-left': [R(3, 4, 18, 16, 2), P('M9 4v16')],
22
+ 'panel-right': [R(3, 4, 18, 16, 2), P('M15 4v16')],
23
+ 'panel-bottom': [R(3, 4, 18, 16, 2), P('M3 14h18')],
24
+ close: [P('M6 6l12 12M18 6 6 18')],
25
+ 'chevron-down': [P('m6 9 6 6 6-6')],
26
+ 'chevron-up': [P('m6 15 6-6 6 6')],
27
+ 'chevron-right': [P('m9 6 6 6-6 6')],
28
+ 'chevron-left': [P('m15 6-6 6 6 6')],
29
+ play: [P('M8 5.5v13l10-6.5Z')],
30
+ pause: [P('M8 5.5v13M16 5.5v13')],
31
+ plan: [P('M4 5v14M10 5v14M16 5v14'), R(5.5, 7, 3, 3, .8), R(11.5, 9, 3, 3, .8), R(11.5, 14, 3, 3, .8), R(17.5, 11, 3, 3, .8)],
32
+ exec: [P('M3 5v14'), R(5, 6, 8, 3, 1), R(9, 10.5, 10, 3, 1), R(6, 15, 6, 3, 1)],
33
+ files: [P('M6 3.5h8l4 4v13H6Z'), P('M14 3.5v4h4M9 12h6M9 15.5h6')],
34
+ cube: [P('M12 3 20 7.5v9L12 21l-8-4.5v-9Z'), P('M4 7.5 12 12l8-4.5M12 12v9')],
35
+ square: [R(5, 5, 14, 14, 1.5), P('M5 12h14M12 5v14')],
36
+ layers: [P('m12 4 9 4.5-9 4.5-9-4.5Z'), P('m3 12.5 9 4.5 9-4.5'), P('m3 16.5 9 4.5 9-4.5')],
37
+ fit: [P('M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5'), R(9, 9, 6, 6, 1)],
38
+ persp: [P('M3.5 19 8.5 5h7l5 14Z'), P('M10.5 5 9.5 19M13.5 5l1 14M6 12h12')],
39
+ iso: [P('M12 4.5 18.5 8v8L12 19.5 5.5 16V8Z'), P('M5.5 8 12 11.5 18.5 8M12 11.5v8')],
40
+ top: [R(4, 4, 16, 16, 2), R(8.5, 8.5, 7, 7, 1)],
41
+ reset: [P('M4.5 12a7.5 7.5 0 1 0 2.2-5.3'), P('M4.5 4.5v3.5H8')],
42
+ 'zoom-in': [C(11, 11, 6.5), P('m16 16 4.5 4.5M8.5 11h5M11 8.5v5')],
43
+ 'zoom-out': [C(11, 11, 6.5), P('m16 16 4.5 4.5M8.5 11h5')],
44
+ fullscreen: [P('M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5')],
45
+ legend: [C(6, 7, 1.5), C(6, 12, 1.5), C(6, 17, 1.5), P('M10 7h9M10 12h9M10 17h6')],
46
+ help: [C(12, 12, 9), P('M9.5 9.5a2.6 2.6 0 0 1 5 1c0 1.8-2.5 2.2-2.5 3.8'), C(12, 17, .6)],
47
+ sort: [P('M8 5v14M5 8l3-3 3 3M16 19V5M13 16l3 3 3-3')],
48
+ 'sort-asc': [P('M12 19V5M7 10l5-5 5 5')],
49
+ 'sort-desc': [P('M12 5v14M7 14l5 5 5-5')],
50
+ columns: [R(3, 4, 18, 16, 2), P('M9 4v16M15 4v16')],
51
+ grip: [C(9, 7, .9), C(15, 7, .9), C(9, 12, .9), C(15, 12, .9), C(9, 17, .9), C(15, 17, .9)],
52
+ download: [P('M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14')],
53
+ flag: [P('M6 21V4M6 4.5h11l-2.5 4 2.5 4H6')],
54
+ check: [P('m5 12.5 4.5 4.5L19 7.5')],
55
+ cross: [P('M6 6l12 12M18 6 6 18')],
56
+ filter: [P('M4 5h16l-6 7.5V19l-4-2v-4.5Z')],
57
+ arrow: [P('M5 12h14M13 6l6 6-6 6')],
58
+ eye: [P('M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z'), C(12, 12, 3)],
59
+ route: [C(5, 17, 2), C(19, 7, 2), P('M7 17h4l2-10h4')],
60
+ keyboard: [R(2.5, 6, 19, 12, 2), P('M6 10h1M9.5 10h1M13 10h1M16.5 10h1M7 14h10')],
61
+ chat: [P('M4 5.5h16v10.5H10l-4.5 3.5V16H4Z'), P('M8 9.5h8M8 12.5h5')],
62
+ terminal: [R(3, 4.5, 18, 15, 2), P('m7 9.5 3 2.5-3 2.5M12.5 15H17')],
63
+ pencil: [P('M4.5 19.5 5.5 15 15.5 5a2.1 2.1 0 0 1 3 3l-10 10Z'), P('m13.5 7 3 3')],
64
+ tool: [P('M14.5 5.5a4 4 0 0 0-5 5L4.5 15.5a2.1 2.1 0 0 0 3 3l5-5a4 4 0 0 0 5-5l-2.4 2.4-2.4-.6-.6-2.4Z')],
65
+ thought: [P('M7 15.5a5 5 0 1 1 8.5-3.5 4 4 0 0 1 2 7.5H8a3 3 0 0 1-1-4Z'), C(6, 20.5, .8)],
66
+ };
67
+ function icon(name, title) {
68
+ const svg = document.createElementNS(NS, 'svg');
69
+ svg.setAttribute('viewBox', '0 0 24 24');
70
+ svg.setAttribute('class', 'icon icon-' + name);
71
+ svg.setAttribute('focusable', 'false');
72
+ if (title) { const t = document.createElementNS(NS, 'title'); t.textContent = title; svg.append(t); svg.setAttribute('role', 'img'); }
73
+ else svg.setAttribute('aria-hidden', 'true');
74
+ for (const [tag, attrs] of SET[name] || SET.square) {
75
+ const n = document.createElementNS(NS, tag);
76
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
77
+ svg.append(n);
78
+ }
79
+ return svg;
80
+ }
81
+ // a model's name plate: the alias engraved, the effort beside it ("luna:high" → LUNA | high); a bare id stays whole
82
+ function modelPlate(spec) {
83
+ const s = String(spec || '').trim(), i = s.lastIndexOf(':'), out = document.createElement('span');
84
+ out.className = 'model-plate'; out.title = s;
85
+ if (!s) { out.textContent = '—'; out.className = 'muted'; return out; }
86
+ const name = document.createElement('span'); name.className = 'mp-name'; name.textContent = i > 0 ? s.slice(0, i) : s; out.append(name);
87
+ if (i > 0 && i < s.length - 1) { // the colon stays in the text, for copying and screen readers
88
+ const c = document.createElement('span'); c.className = 'visually-hidden'; c.textContent = ':';
89
+ const e = document.createElement('span'); e.className = 'mp-effort'; e.textContent = s.slice(i + 1); out.append(c, e);
90
+ }
91
+ return out;
92
+ }
93
+ return { icon, modelPlate, names: Object.keys(SET) };
94
+ })();
@@ -13,7 +13,11 @@
13
13
  } }
14
14
  </script>
15
15
  <link rel="icon" href="favicon.svg" type="image/svg+xml">
16
+ <link rel="preload" href="D-DINCondensed-Bold.woff2" as="font" type="font/woff2" crossorigin>
16
17
  <link rel="stylesheet" href="style.css">
18
+ <script src="theme.js"></script>
19
+ <script src="icons.js" defer></script>
20
+ <script src="datatable.js" defer></script>
17
21
  <script src="runmodel.js" defer></script>
18
22
  <script src="cloudmodel.js" defer></script>
19
23
  <script src="modelstats.js" defer></script>
@@ -21,6 +25,7 @@
21
25
  <script src="gantt.js" defer></script>
22
26
  <script src="panel.js" defer></script>
23
27
  <script src="diffview.js" defer></script>
28
+ <script src="convo.js" defer></script>
24
29
  <script src="files.js" defer></script>
25
30
  <script src="map3d.js" defer></script>
26
31
  <script src="cloudui.js" defer></script>
@@ -29,23 +34,30 @@
29
34
  <script src="notify.js" defer></script>
30
35
  <script src="app.js" defer></script>
31
36
  </head>
32
- <body>
37
+ <body data-shell="immersive" data-runs="open" data-insp="open" data-tasks="open">
38
+ <script src="shell.js"></script>
33
39
  <header class="topbar">
34
- <a class="brand" href="#/">forge-ui</a>
35
- <a class="top-link" href="#/">Map of all runs</a>
36
- <a class="top-link" href="#/cloud">Run cloud</a>
37
- <a class="top-link" href="#/models">Models</a>
38
- <span class="muted">Local run viewer · read only</span>
39
- <button class="top-search" type="button" data-jump title="Jump to a run, task, project, model or command (/)">Search <kbd>Ctrl K</kbd></button>
40
- <button id="bell" class="bell" type="button" aria-label="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3a6 6 0 0 0-6 6v3.6L4.3 15.4A1 1 0 0 0 5.2 17h13.6a1 1 0 0 0 .9-1.6L18 12.6V9a6 6 0 0 0-6-6Zm-2.6 15.5a2.7 2.7 0 0 0 5.2 0Z"/></svg><span class="bell-count" hidden></span></button>
41
- <label class="refresh-control"><input id="refresh" type="checkbox" checked> Live refresh</label>
40
+ <a class="brand" href="#/" title="forge-ui · local run viewer, read only"><span class="brand-plate">forge</span><span class="brand-sub">ui</span></a>
41
+ <nav class="desk-nav" aria-label="Views">
42
+ <a class="top-link" href="#/">Map</a>
43
+ <a class="top-link" href="#/cloud">Cloud</a>
44
+ <a class="top-link" href="#/models">Models</a>
45
+ </nav>
46
+ <button class="top-search" type="button" data-jump title="Jump to a run, task, project, model or command (/)"><span class="top-search-label"><span>Search</span></span> <kbd>Ctrl K</kbd></button>
47
+ <div class="desk-tools">
48
+ <button id="bell" class="bell" type="button" aria-label="Notifications"><span class="bell-count" hidden></span></button>
49
+ <label class="refresh-control" title="Poll the runs for changes"><input id="refresh" type="checkbox" checked> Live</label>
50
+ </div>
42
51
  </header>
43
52
  <div id="errors" role="status" aria-live="polite" hidden></div>
44
53
  <div class="layout">
45
- <aside class="sidebar" aria-label="Runs">
46
- <div class="sidebar-heading"><h1>Runs</h1><span id="run-count" class="muted"></span></div>
47
- <label class="filter-label" for="filter">Filter by goal, repo, ID, path or model</label>
48
- <input id="filter" type="search" placeholder="Filter runs…" autocomplete="off">
54
+ <aside class="sidebar module" aria-label="Runs" data-overlay>
55
+ <div class="module-head"><h1 class="module-title">Runs</h1><span id="run-count" class="module-count"></span><span class="module-tools"></span></div>
56
+ <div class="runs-tools">
57
+ <label class="visually-hidden" for="filter">Filter by goal, repo, ID, path or model</label>
58
+ <input id="filter" type="search" placeholder="Filter runs…" autocomplete="off">
59
+ <select id="run-sort" aria-label="Sort the runs"><option value="updated">Latest</option><option value="state">State</option><option value="repo">Repo</option></select>
60
+ </div>
49
61
  <nav id="run-list" aria-label="Choose a run"><p class="empty">Loading runs…</p></nav>
50
62
  </aside>
51
63
  <main id="main" tabindex="-1"><p class="empty">Loading…</p></main>
@@ -41,14 +41,14 @@ const W = (x, y, z) => new THREE.Vector3(x * TS, z * TS, y * TS); // plan x ri
41
41
  const MONO3 = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
42
42
  const NOISE = `float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }`;
43
43
  const CW = .61; // monospace advance, in em
44
- // same tokens as the iso figures' light and dark themes
44
+ // the iso figures' light and dark themes, tinted to forge-ui's enamel panel; style.css's --stage-* tokens win when set
45
45
  const THEMES = {
46
- dark: { bg: 0x121314, body: 0x1b1c1f, line: 0x4f5357, major: 0x393c3f, edge: 0x27292c, sky: 0xdde4ff, ground: 0x080808, hemi: .7, amb: 0, sun: .7, fill: .2, fadeMin: .45 },
47
- light: { bg: 0xe9eaea, body: 0xf1f1f0, line: 0x5d6266, major: 0x8a8f93, edge: 0xb9bcbf, sky: 0xffffff, ground: 0xe6e6e6, hemi: .9, amb: 1.6, sun: .35, fill: .15, fadeMin: .8 },
46
+ dark: { bg: 0x0e1210, body: 0x161c19, line: 0x4f5b54, major: 0x36413b, edge: 0x242c28, recess: 0x0b0e0c, sky: 0xe4efe6, ground: 0x070908, hemi: .7, amb: 0, sun: .7, fill: .2, fadeMin: .45 },
47
+ light: { bg: 0xc6cec4, body: 0xf3f5f0, line: 0x454f48, major: 0x6f7a72, edge: 0x9aa49b, recess: 0xdce1d9, sky: 0xffffff, ground: 0xdfe5dc, hemi: .9, amb: 1.6, sun: .35, fill: .15, fadeMin: .85 },
48
48
  };
49
49
  const PAL = {
50
- light: { line: '#8e9397', detail: '#c3c6c8', recess: '#dcdddd', ink: '#6a6f73', inkHi: '#16181a', glassOff: '#e4e5e5', glassOn: '#f6ece2', bg: '#f6f6f5' },
51
- dark: { line: '#4f5357', detail: '#2b2e30', recess: '#0f1011', ink: '#868b8f', inkHi: '#e6e8e9', glassOff: '#141516', glassOn: '#22170f', bg: '#121314' },
50
+ light: { line: '#6f7a72', detail: '#a9b2a8', recess: '#dce1d9', ink: '#2c342f', inkHi: '#111613', glassOff: '#e3e7df', glassOn: '#f6ead0', bg: '#f3f5f0' },
51
+ dark: { line: '#4f5b54', detail: '#28302c', recess: '#0b0e0c', ink: '#86928b', inkHi: '#e4e8e2', glassOff: '#121714', glassOn: '#221d0e', bg: '#0e1210' },
52
52
  };
53
53
 
54
54
  /* ---------- text: wrap and fit monospace print into a box (archivision's figure.js) ---------- */
@@ -287,7 +287,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
287
287
  function palette() {
288
288
  const C = PAL[theme], light = theme === 'light', hi = TMPC.copy(accent).lerp(new THREE.Color(light ? 0x000000 : 0xffffff), light ? .3 : .42);
289
289
  PALC = { ...C, acc: '#' + accent.getHexString(), accHi: '#' + hi.getHexString(), light };
290
- INKHI.set(C.inkHi); DETAIL.set(light ? '#c3c6c8' : '#34373a'); GLASSON.set(C.glassOn);
290
+ INKHI.set(C.inkHi); DETAIL.set(C.detail); GLASSON.set(C.glassOn);
291
291
  }
292
292
  palette();
293
293
  const setOp = (m, o) => { m.opacity = o; const t = o < .999; if (m.transparent !== t) { m.transparent = t; m.needsUpdate = true; } };
@@ -391,18 +391,25 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
391
391
  const probe = document.createElement('span');
392
392
  probe.className = 'stage-probe'; probe.setAttribute('aria-hidden', 'true'); host.append(probe);
393
393
  const COLORS = new Map();
394
+ // a custom property's colour, resolved by the browser (tokens are light-dark() pairs, so the raw value is not a colour)
394
395
  function cssColor(className, prop) {
395
396
  const key = className + '|' + prop;
396
397
  if (!COLORS.has(key)) {
397
398
  probe.className = 'stage-probe ' + className;
398
- const v = getComputedStyle(probe).getPropertyValue(prop).trim();
399
+ probe.style.setProperty('color', `var(${prop})`);
400
+ const cs = getComputedStyle(probe), v = cs.getPropertyValue(prop).trim() ? cs.color : '';
399
401
  let c = null; try { if (v) c = new THREE.Color(v); } catch (_) { c = null; }
400
402
  COLORS.set(key, c);
401
403
  }
402
404
  return COLORS.get(key);
403
405
  }
404
- const statusColor = s => cssColor('st-' + String(s || 'unknown').toLowerCase().replace(/[^a-z0-9_-]/g, ''), '--st') || new THREE.Color('#888888');
405
- const statusHex = s => '#' + statusColor(s).getHexString();
406
+ // a state's lamp as lit on the plate (--st-lit), and as ink for print on it (--st)
407
+ const stClass = s => 'st-' + String(s || 'unknown').toLowerCase().replace(/[^a-z0-9_-]/g, '');
408
+ const statusColor = s => cssColor(stClass(s), '--st-lit') || cssColor(stClass(s), '--st') || new THREE.Color('#888888');
409
+ const statusHex = s => '#' + (cssColor(stClass(s), '--st') || statusColor(s)).getHexString();
410
+ // a finding severity's colour (--sev on .sev-P0…P3): a ramp of its own, never a status lamp
411
+ const sevColor = p => cssColor('sev-' + String(p).replace(/[^A-Za-z0-9]/g, ''), '--sev') || statusColor('unknown');
412
+ const sevHex = p => '#' + sevColor(p).getHexString();
406
413
 
407
414
  /* ---------- theme: follows the page (prefers-color-scheme), colours from style.css ---------- */
408
415
  const themeHooks = new Set();
@@ -413,9 +420,10 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
413
420
  const bg = cssColor('', '--stage-bg'), acc = cssColor('', '--stage-accent');
414
421
  BG_COL.set(bg || T.bg); scene.background.copy(BG_COL);
415
422
  if (acc) accent.copy(acc);
416
- bodyMat.color.set(T.body);
417
- LINE_COL.set(T.line); MAJOR_COL.set(T.major); EDGE_COL.set(T.edge); appliedKey = '';
418
- recessMat.color.set(light ? 0xdcdddd : 0x0f1011);
423
+ const tok = (name, d) => cssColor('', name) || new THREE.Color(d);
424
+ bodyMat.color.copy(tok('--stage-body', T.body));
425
+ LINE_COL.copy(tok('--stage-line', T.line)); MAJOR_COL.copy(tok('--stage-major', T.major)); EDGE_COL.copy(tok('--stage-edge', T.edge)); appliedKey = '';
426
+ recessMat.color.copy(tok('--stage-recess', T.recess));
419
427
  accentLineMat.color.copy(accent);
420
428
  hemi.color.set(T.sky); hemi.groundColor.set(T.ground); hemi.intensity = T.hemi; ambient.intensity = T.amb; sun.intensity = T.sun; fill.intensity = T.fill;
421
429
  for (const m of glowMats) { m.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; m.uniforms.light.value = light ? 1 : 0; m.needsUpdate = true; m.visible = !light; }
@@ -425,16 +433,20 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
425
433
  for (const t of TXT) if (!t.gone) t.redraw();
426
434
  invalidate();
427
435
  }
436
+ // the page's theme: forge-ui's own switch (theme.js) when it is there, else the system setting
428
437
  const media = matchMedia('(prefers-color-scheme: light)');
429
- const onScheme = () => setTheme(media.matches ? 'light' : 'dark');
438
+ const resolved = () => window.ForgeTheme ? window.ForgeTheme.resolved() : media.matches ? 'light' : 'dark';
439
+ const onScheme = () => setTheme(resolved());
430
440
  media.addEventListener('change', onScheme);
441
+ window.addEventListener('forge-theme', onScheme);
431
442
 
432
443
  function applyWeight() {
433
444
  const dist = camera.position.distanceTo(controls.target);
434
445
  const ppm = camera === ortho ? VH * ortho.zoom / orthoH : VH / (dist * fovK());
435
446
  const k = THREE.MathUtils.clamp((ppm / 40 - .35) / .65, 0, 1);
436
447
  const fm = THEMES[theme].fadeMin, fade = fm + (1 - fm) * k, dpr = renderer.getPixelRatio();
437
- const key = fade.toFixed(2) + '|' + dpr + '|' + theme + '|' + (camera === ortho); if (key === appliedKey) return; appliedKey = key;
448
+ // orthographic zoom too: a screen sprite's world size follows it
449
+ const key = fade.toFixed(2) + '|' + dpr + '|' + theme + '|' + (camera === ortho ? ortho.zoom.toFixed(4) : 'p'); if (key === appliedKey) return; appliedKey = key;
438
450
  lineMat.linewidth = majorMat.linewidth = dpr; accentLineMat.linewidth = 1.6 * dpr;
439
451
  lineMat.color.copy(BG_COL).lerp(EDGE_COL, fade);
440
452
  majorMat.color.copy(BG_COL).lerp(MAJOR_COL, fade);
@@ -443,6 +455,8 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
443
455
  for (const f of weightHooks) f();
444
456
  }
445
457
  const weightHooks = new Set();
458
+ // screen pixels per plan unit at the point the camera looks at
459
+ const pxPerUnit = () => (camera === ortho ? VH * ortho.zoom / orthoH : VH / (camera.position.distanceTo(controls.target) * fovK())) * TS;
446
460
 
447
461
  /* ---------- camera: frame a box, keeping the user's angle ---------- */
448
462
  const fovK = () => 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2));
@@ -484,14 +498,33 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
484
498
  let D = .5;
485
499
  for (const p0 of pts) {
486
500
  const p = p0.clone().sub(c), px = p.dot(r), py = p.dot(u), pz = p.dot(f);
487
- D = Math.max(D, Math.abs(px) / (th * fw * .96) - pz, Math.abs(py) / (tv * fh * .92) - pz);
501
+ D = Math.max(D, Math.abs(px) / (th * fw * .97) - pz, Math.abs(py) / (tv * fh * .95) - pz);
488
502
  }
489
503
  // what is framed sits in the middle of the uncovered part of the stage
490
504
  c.addScaledVector(r, -(I.l - I.r) / VW * D * th).addScaledVector(u, (I.t - I.b) / VH * D * tv);
491
505
  return { c, D };
492
506
  }
507
+ // the stage covered by modules floating over it (marked data-overlay): a tall one covers a side, a wide one a top or bottom
508
+ function overlayInset() {
509
+ const H = host.getBoundingClientRect(), I = { l: 0, r: 0, t: 0, b: 0 };
510
+ if (!H.width || !H.height) return I;
511
+ const full = document.fullscreenElement;
512
+ for (const n of document.querySelectorAll('[data-overlay]')) {
513
+ if (full && !full.contains(n)) continue; // full screen: the modules are behind the stage
514
+ const R = n.getBoundingClientRect();
515
+ if (!R.width || !R.height || R.right <= H.left || R.left >= H.right || R.bottom <= H.top || R.top >= H.bottom) continue;
516
+ const cl = Math.max(R.left, H.left), cr = Math.min(R.right, H.right), ct = Math.max(R.top, H.top), cb = Math.min(R.bottom, H.bottom);
517
+ if (cb - ct > H.height * .5) { if (cl - H.left < H.right - cr) I.l = Math.max(I.l, cr - H.left); else I.r = Math.max(I.r, H.right - cl); }
518
+ else if (ct - H.top < H.bottom - cb) I.t = Math.max(I.t, cb - H.top); else I.b = Math.max(I.b, H.bottom - ct);
519
+ }
520
+ return I;
521
+ }
522
+ // the last framing, redone when the stage changes size, until the user moves the camera themselves
523
+ let autoFrame = null;
493
524
  function frameBox(bx, dur = 900, minEl = 26, dirOverride = null, inset = null) {
494
525
  resize(); // the host may have just been shown or moved: fit its real size
526
+ autoFrame = { bx, minEl, dirOverride, inset };
527
+ if (!inset) inset = overlayInset();
495
528
  let dir = dirOverride ? dirOverride.clone().normalize() : camera.position.clone().sub(controls.target);
496
529
  dir = dir.lengthSq() > 1e-8 ? dir.normalize() : DIR_ISO.clone();
497
530
  const el = Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), lo = THREE.MathUtils.degToRad(minEl);
@@ -515,6 +548,22 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
515
548
  if (k >= 1) { controls.enableDamping = true; CAMT = null; }
516
549
  return true;
517
550
  }
551
+ // closer (k > 1) or farther (k < 1) toward what the camera looks at, as a short glide; the angle stays
552
+ function zoomBy(k, dur = 260) {
553
+ settleCam();
554
+ const off = camera.position.clone().sub(controls.target);
555
+ let zoom = null;
556
+ if (camera === ortho) zoom = THREE.MathUtils.clamp(ortho.zoom * k, .05, 40);
557
+ else off.setLength(THREE.MathUtils.clamp(off.length() / k, controls.minDistance || .01, controls.maxDistance));
558
+ autoFrame = null;
559
+ if (reduceMotion || dur <= 1) {
560
+ camera.position.copy(controls.target).add(off);
561
+ if (zoom) { ortho.zoom = zoom; ortho.updateProjectionMatrix(); }
562
+ invalidate(); return;
563
+ }
564
+ CAMT = { t0: performance.now(), dur, fromT: controls.target.clone(), fromO: camera.position.clone().sub(controls.target), fromZ: zoom ? ortho.zoom : null, to: { t: controls.target.clone(), off, zoom } };
565
+ controls.enableDamping = false; invalidate();
566
+ }
518
567
  const cancelCam = () => { if (CAMT) { controls.enableDamping = true; CAMT = null; } };
519
568
  const settleCam = () => { if (CAMT) { camStep(CAMT.t0 + CAMT.dur + 1); controls.update(); } }; // jump to where a glide ends (tests)
520
569
 
@@ -544,6 +593,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
544
593
  else if (!raf) last = 0;
545
594
  }
546
595
  controls.addEventListener('change', () => invalidate());
596
+ controls.addEventListener('start', () => { autoFrame = null; });
547
597
  const onVisibility = () => { visible = document.visibilityState === 'visible'; if (visible) invalidate(); };
548
598
  document.addEventListener('visibilitychange', onVisibility);
549
599
  const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(es => { onScreen = es.some(e => e.isIntersecting); if (onScreen) invalidate(); }) : null;
@@ -556,6 +606,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
556
606
  const res = devRes();
557
607
  outlineMat.uniforms.res.value.copy(res); lineMat.resolution.copy(res); majorMat.resolution.copy(res); accentLineMat.resolution.copy(res);
558
608
  appliedKey = ''; invalidate();
609
+ if (autoFrame) { const f = autoFrame; frameBox(f.bx, 0, f.minEl, f.dirOverride, f.inset); }
559
610
  }
560
611
  const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
561
612
  if (ro) ro.observe(host);
@@ -740,7 +791,7 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
740
791
  }
741
792
  function dispose() {
742
793
  disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0;
743
- media.removeEventListener('change', onScheme); document.removeEventListener('visibilitychange', onVisibility);
794
+ media.removeEventListener('change', onScheme); window.removeEventListener('forge-theme', onScheme); document.removeEventListener('visibilitychange', onVisibility);
744
795
  if (io) io.disconnect(); if (ro) ro.disconnect();
745
796
  controls.dispose(); disposeGroup(scene); target.dispose(); composer.dispose && composer.dispose(); renderer.dispose();
746
797
  canvas.remove(); probe.remove();
@@ -756,9 +807,9 @@ export async function createEngine(host, { reduceMotion = false } = {}) {
756
807
  glowPoints, softMat, fat, loopL, noRay, rbox, inset, seg, polyLine, cumLen, alongPts, moveGlow, roundRect,
757
808
  mats, lmat, paint, lineCol, setOp, solid, loopAt, roofPlate, cone,
758
809
  tplane, release, roofAt, frontAt, lines, rr, font, fit, wrap, ellipsis, pxIn,
759
- ends, curve, arc, statusColor, statusHex, cssColor,
760
- frameBox, fitFor, cancelCam, settleCam, setOrtho, isOrtho: () => camera === ortho, invalidate, tickers, themeHooks, weightHooks,
761
- pick, pickHit, rayAt, project, handlers, disposeGroup, dispose, reduceMotion, now: () => NOW,
810
+ ends, curve, arc, statusColor, statusHex, sevColor, sevHex, cssColor,
811
+ frameBox, fitFor, zoomBy, cancelCam, settleCam, setOrtho, overlayInset, isOrtho: () => camera === ortho, invalidate, tickers, themeHooks, weightHooks,
812
+ pick, pickHit, rayAt, project, pxPerUnit, handlers, disposeGroup, dispose, reduceMotion, now: () => NOW,
762
813
  setProfile, get profile() { return profile; }, unitSolid, solidFrom, disposeShared, sprite, outlineInstanced, mixColor,
763
814
  LineSegments2, LineSegmentsGeometry, creaseLines,
764
815
  };