crewforth 3.2.0-rc.2 → 3.2.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -5
- package/README.md +2 -0
- package/VERSION +1 -1
- package/adopt.sh +10 -1
- package/bin/cli.js +3 -2
- package/kit/CLAUDE.md +3 -3
- package/kit/agents/crew-commit-agent.md +6 -4
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/eval/doctor.sh +13 -2
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/lib/crew-map/narrative.mjs +42 -8
- package/kit/eval/lib/crew-map/project.mjs +3 -2
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +56 -4
- package/kit/eval/lib/crew-map/stacks.mjs +114 -8
- package/kit/eval/lib/crew-map/surface.mjs +62 -1
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-json.awk +4 -0
- package/kit/eval/lib/statusline.sh +1 -1
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +208 -21
- package/kit/hooks/agent-outcome.sh +18 -0
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +72 -26
- package/kit/hooks/guard-bash.sh +49 -34
- package/kit/hooks/guard-write.sh +3 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +21 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/settings.json +12 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-map/SKILL.md +16 -5
- package/kit/skills/crew-review/SKILL.md +2 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/kit/studio/README.md +21 -10
- package/kit/studio/server/index.js +74 -22
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +104 -18
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/web/app.js +69 -7
- package/kit/studio/web/canvas.js +14 -2
- package/kit/studio/web/chat.js +32 -7
- package/kit/studio/web/graph-plan.js +3 -2
- package/kit/studio/web/map-plan.js +72 -3
- package/kit/studio/web/map.js +103 -10
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +34 -0
- package/kit/studio/web/tokens.css +6 -0
- package/package.json +1 -1
- package/start.sh +14 -1
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
// What the Map view draws, worked out from a crew map. No page in it.
|
|
2
2
|
//
|
|
3
3
|
// The map is Crewforth's (server/lib/crew-map.js reads it). This file puts each node in a band, says which lines
|
|
4
|
-
// a selection lights, orders the findings and words them. It invents no layer: a module
|
|
5
|
-
//
|
|
4
|
+
// a selection lights, orders the findings and words them. It invents no layer: a module's layer is the one /crew-map
|
|
5
|
+
// wrote for it (citing the lines it rests on); without one, its band is what the scan shows of it, and a module whose
|
|
6
|
+
// place the scan does not show goes to "Unplaced". A finding the scanner hides by default stays hidden until asked.
|
|
6
7
|
|
|
8
|
+
// The layers /crew-map may write (eval/lib/crew-map/narrative.mjs LAYERS), set between the bands the scan alone
|
|
9
|
+
// gives, so a map with no layer is drawn in the same order as before.
|
|
10
|
+
export const LAYER_KEYS = ['ui', 'api', 'service', 'data', 'infra', 'tool', 'library'];
|
|
7
11
|
export const BANDS = [
|
|
12
|
+
['ui', 'UI'],
|
|
8
13
|
['clients', 'Clients'],
|
|
14
|
+
['api', 'API'],
|
|
9
15
|
['entry', 'Entry points'],
|
|
16
|
+
['service', 'Service'],
|
|
10
17
|
['core', 'Core'],
|
|
18
|
+
['data', 'Data'],
|
|
11
19
|
['infra', 'Infrastructure'],
|
|
20
|
+
['library', 'Library'],
|
|
21
|
+
['tool', 'Tools'],
|
|
12
22
|
['external', 'External'],
|
|
13
23
|
['unplaced', 'Unplaced'],
|
|
14
24
|
];
|
|
@@ -37,8 +47,10 @@ export function findingModules(f, modules) {
|
|
|
37
47
|
* one that only calls endpoints → Clients
|
|
38
48
|
* one another module of this project references → Core · one with an entry point and nothing above → Entry points
|
|
39
49
|
* anything else → Unplaced, so a guess is never drawn as a fact.
|
|
50
|
+
* A module /crew-map gave a layer goes to that layer's band first.
|
|
40
51
|
*/
|
|
41
52
|
export function bandOf(node, doc) {
|
|
53
|
+
if (node.kind === 'module' && LAYER_KEYS.includes(node.narrative?.layer)) return node.narrative.layer;
|
|
42
54
|
if (node.kind === 'external') return 'external';
|
|
43
55
|
if (node.kind === 'service') return 'infra';
|
|
44
56
|
if (node.kind === 'client') return 'clients';
|
|
@@ -201,6 +213,52 @@ export function cardSub(node, doc) {
|
|
|
201
213
|
return [tech || node.stack, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
|
|
202
214
|
}
|
|
203
215
|
|
|
216
|
+
/**
|
|
217
|
+
* What a module's card says, as the Map draws it: a kind line (its domain, else its stack, and how many endpoints it
|
|
218
|
+
* provides), up to three of what it is built with, and the outside services it calls, folded into one line under it.
|
|
219
|
+
*/
|
|
220
|
+
export const CARD_CHIPS = 3;
|
|
221
|
+
export function cardOf(node, doc) {
|
|
222
|
+
const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
|
|
223
|
+
const kind = [node.narrative?.domain ?? node.stack ?? null, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
|
|
224
|
+
const tech = (node.tech ?? []).map((t) => t.replace(/@.*$/, ''));
|
|
225
|
+
return { kind, chips: tech.slice(0, CARD_CHIPS), more: Math.max(0, tech.length - CARD_CHIPS), outside: outsideOf(node, doc) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The outside services a module calls, by name, in the order they are named: what its card folds under it. */
|
|
229
|
+
export function outsideOf(node, doc) {
|
|
230
|
+
const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
|
|
231
|
+
const names = [];
|
|
232
|
+
for (const e of doc.edges ?? []) {
|
|
233
|
+
if (e.from !== node.id) continue;
|
|
234
|
+
const to = byId.get(e.to);
|
|
235
|
+
if (to?.kind !== 'external') continue;
|
|
236
|
+
const name = to.label && to.label !== 'unknown' ? to.label : (to.hosts ?? [])[0] ?? to.id.replace(/^ext:[^:]+:/, '');
|
|
237
|
+
if (!names.includes(name)) names.push(name);
|
|
238
|
+
}
|
|
239
|
+
return names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The tour /crew-map wrote, the stops whose node is on this map, each with the node's name. */
|
|
243
|
+
export function tourOf(doc) {
|
|
244
|
+
const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
|
|
245
|
+
return (Array.isArray(doc.tour) ? doc.tour : []).filter((s) => s && byId.has(s.node) && typeof s.text === 'string')
|
|
246
|
+
.map((s) => ({ node: s.node, name: byId.get(s.node).name ?? byId.get(s.node).label ?? s.node, text: s.text, cites: (s.cites ?? []).filter((c) => typeof c === 'string') }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The nodes a search names: by name, folder, what they are built with, their domain, the folders inside them and
|
|
251
|
+
* what their README says. Case does not matter; an empty search names none.
|
|
252
|
+
*/
|
|
253
|
+
export function searchOf(query, nodes) {
|
|
254
|
+
const q = String(query ?? '').trim().toLowerCase();
|
|
255
|
+
if (!q) return new Set();
|
|
256
|
+
const hay = (n) => [n.name, n.label, n.path, n.stack, ...(n.tech ?? []), ...(n.hosts ?? []), n.narrative?.domain, n.narrative?.layer,
|
|
257
|
+
...(n.structure?.folders ?? []).map((f) => f.folder), ...(n.about?.evidence ?? []).map((e) => e.text)]
|
|
258
|
+
.filter((x) => typeof x === 'string').join('\n').toLowerCase();
|
|
259
|
+
return new Set(nodes.filter((n) => hay(n).includes(q)).map((n) => n.id));
|
|
260
|
+
}
|
|
261
|
+
|
|
204
262
|
/** The summary pills: what the map holds, and how many findings the list shows. */
|
|
205
263
|
export function summaryOf(doc, findings) {
|
|
206
264
|
const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
|
|
@@ -247,6 +305,16 @@ export function inspectOf(node, doc, edges, findings) {
|
|
|
247
305
|
narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
|
|
248
306
|
narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
|
|
249
307
|
narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
|
|
308
|
+
// /crew-map's layer and domain, when it wrote them; the band is the layer's (bandOf).
|
|
309
|
+
layer: LAYER_KEYS.includes(node.narrative?.layer) ? (BANDS.find(([k]) => k === node.narrative.layer)?.[1] ?? null) : null,
|
|
310
|
+
domain: typeof node.narrative?.domain === 'string' ? node.narrative.domain : null,
|
|
311
|
+
// What is inside it: the entry point the scan found, its folders with how many source files each holds, tests apart.
|
|
312
|
+
inside: node.kind === 'module' ? {
|
|
313
|
+
entry: node.entryPoint?.evidence?.[0] ? `${node.entryPoint.evidence[0].file}:${node.entryPoint.evidence[0].line}` : (node.entryPoint?.file ?? null),
|
|
314
|
+
folders: (node.structure?.folders ?? []).map((f) => ({ folder: f.folder, files: f.files })),
|
|
315
|
+
other: node.structure?.otherFiles ?? 0,
|
|
316
|
+
tests: node.structure?.testFiles ?? 0,
|
|
317
|
+
} : null,
|
|
250
318
|
connectsTo: out,
|
|
251
319
|
connections: { out, in: into },
|
|
252
320
|
byKind: Object.entries(byKind).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
@@ -274,7 +342,8 @@ export function emptyNote(data) {
|
|
|
274
342
|
return null;
|
|
275
343
|
}
|
|
276
344
|
|
|
277
|
-
|
|
345
|
+
// A module card holds its name and kind, a row of what it is built with, and the line of outside services under it.
|
|
346
|
+
export const CARD = { module: [280, 104], service: [280, 104], client: [280, 104], group: [280, 104], external: [104, 52] };
|
|
278
347
|
const GAP_X = 24;
|
|
279
348
|
const GAP_Y = 20;
|
|
280
349
|
const LEFT = 140; // the band's label sits in this column
|
package/kit/studio/web/map.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// The Map view: the project as Crewforth's crew map shows it.
|
|
2
2
|
//
|
|
3
|
-
// Bands from the
|
|
4
|
-
//
|
|
3
|
+
// Bands from the layers /crew-map wrote (UI, API, Service, Data, Infrastructure, …), or from what the scan shows
|
|
4
|
+
// where it wrote none, on a pane that pans and zooms like the graph's (the shared viewport.js). A module's card
|
|
5
|
+
// carries what it is built with and folds the outside services it calls into one line under it; a search dims the
|
|
6
|
+
// cards it does not name, and the tour /crew-map wrote steps through the map one stop at a time. Lines are solid where a file proves them, dotted where they are inferred, dashed with their
|
|
5
7
|
// own mark where the model proposed them. A card can be placed by hand and stays placed for that project; modules
|
|
6
8
|
// that share a folder can be folded into one card. Under the pane, the findings; beside it, the inspector. On a
|
|
7
9
|
// phone the pane gives way to the findings and a list of the modules. It reads the map the server sends and writes
|
|
8
10
|
// nothing; what leaves the page is copied text: a finding as a task, or a file:line.
|
|
9
11
|
import {
|
|
10
12
|
nodesOf, edgesOf, edgeKey, litEdges, findingsOf, findingCounts, cardSub, summaryOf, sourceOf, inspectOf, taskOf,
|
|
11
|
-
emptyNote, layoutOf, pathOf, chipsOf,
|
|
13
|
+
emptyNote, layoutOf, pathOf, chipsOf, inferredOnly, groupsOf, foldView, placed, BANDS, cardOf, tourOf, searchOf, resourceWord,
|
|
12
14
|
} from './map-plan.js';
|
|
13
15
|
import { Viewport, draggable, swallowClick } from './viewport.js';
|
|
14
16
|
|
|
@@ -30,6 +32,8 @@ const BAND_ORDER = Object.fromEntries(BANDS.map(([k], i) => [k, i]));
|
|
|
30
32
|
const BAND_WORD = Object.fromEntries(BANDS);
|
|
31
33
|
|
|
32
34
|
const LAYERS = [['modules', 'Modules'], ['external', 'External'], ['endpoints', 'Endpoints'], ['findings', 'Findings']];
|
|
35
|
+
// How a line between two modules is named on the map.
|
|
36
|
+
const EDGE_WORD = { references: 'uses', calls: 'calls', builds: 'builds' };
|
|
33
37
|
|
|
34
38
|
export class MapView {
|
|
35
39
|
/**
|
|
@@ -51,6 +55,8 @@ export class MapView {
|
|
|
51
55
|
this.groupOf = {};
|
|
52
56
|
this.lay = null;
|
|
53
57
|
this.fitted = false;
|
|
58
|
+
this.query = '';
|
|
59
|
+
this.tourAt = -1; // the stop shown, or -1 when the tour is off
|
|
54
60
|
this.root.classList.add('mp');
|
|
55
61
|
this.#build();
|
|
56
62
|
}
|
|
@@ -61,8 +67,11 @@ export class MapView {
|
|
|
61
67
|
this.pane = mk('div', 'mp-canvas-pane');
|
|
62
68
|
this.layer = mk('div', 'mp-canvas');
|
|
63
69
|
this.pane.append(this.layer);
|
|
70
|
+
this.tourBox = mk('div', 'mp-tour');
|
|
71
|
+
this.tourBox.hidden = true;
|
|
72
|
+
this.pane.append(this.tourBox);
|
|
64
73
|
this.vp = new Viewport(this.pane, this.layer, {
|
|
65
|
-
skip: (t) => Boolean(t.closest?.('.mp-card') || t.closest?.('.mp-group-fold')),
|
|
74
|
+
skip: (t) => Boolean(t.closest?.('.mp-card') || t.closest?.('.mp-group-fold') || t.closest?.('.mp-tour')),
|
|
66
75
|
onApply: () => this.hooks.onZoom?.(this.vp.view.k),
|
|
67
76
|
});
|
|
68
77
|
this.below = mk('div', 'mp-below');
|
|
@@ -90,7 +99,7 @@ export class MapView {
|
|
|
90
99
|
|
|
91
100
|
setData(data) {
|
|
92
101
|
if (data?.cwd !== this.data?.cwd) {
|
|
93
|
-
this.selected = null; this.tab = 'overview'; this.fitted = false; this.layWidth = null;
|
|
102
|
+
this.selected = null; this.tab = 'overview'; this.fitted = false; this.layWidth = null; this.query = ''; this.tourAt = -1;
|
|
94
103
|
this.#restore(data?.cwd);
|
|
95
104
|
}
|
|
96
105
|
this.data = data;
|
|
@@ -109,6 +118,7 @@ export class MapView {
|
|
|
109
118
|
/** Esc closes the legend first, then lets the selection go. Returns whether it had something to close. */
|
|
110
119
|
escape() {
|
|
111
120
|
if (this.legendOpen) { this.legendOpen = false; this.render(); return true; }
|
|
121
|
+
if (this.tourAt >= 0) { this.tourAt = -1; this.render(); return true; }
|
|
112
122
|
if (!this.selected) return false;
|
|
113
123
|
this.selected = null; this.render(); return true;
|
|
114
124
|
}
|
|
@@ -133,6 +143,14 @@ export class MapView {
|
|
|
133
143
|
return true;
|
|
134
144
|
}
|
|
135
145
|
|
|
146
|
+
/** Step the tour to a stop (clamped), selecting its node and bringing it to the middle; -1 ends it. */
|
|
147
|
+
tour(at) {
|
|
148
|
+
const stops = this.data?.doc ? tourOf(this.data.doc) : [];
|
|
149
|
+
if (at < 0 || !stops.length) { this.tourAt = -1; this.render(); return; }
|
|
150
|
+
this.tourAt = Math.min(at, stops.length - 1);
|
|
151
|
+
this.select(stops[this.tourAt].node, { reveal: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
toggleGroup(id) {
|
|
137
155
|
if (this.folded.has(id)) this.folded.delete(id); else this.folded.add(id);
|
|
138
156
|
this.#persist();
|
|
@@ -155,7 +173,8 @@ export class MapView {
|
|
|
155
173
|
const counts = this.layers.findings ? findingCounts(findings.shown) : {};
|
|
156
174
|
const allEdges = edgesOf(doc, d.compare);
|
|
157
175
|
const all = nodesOf(doc, d.compare);
|
|
158
|
-
|
|
176
|
+
// An outside service is not a card: it is named on the line under the cards of the modules that call it.
|
|
177
|
+
let nodes = all.filter((n) => n.kind !== 'external' && this.layers.modules);
|
|
159
178
|
const groups = groupsOf(nodes);
|
|
160
179
|
this.groupOf = {};
|
|
161
180
|
for (const g of Object.values(groups)) for (const m of g.members) if (this.folded.has(g.id)) this.groupOf[m] = g.id;
|
|
@@ -179,6 +198,7 @@ export class MapView {
|
|
|
179
198
|
const lay = this.lay;
|
|
180
199
|
const shownSel = this.selected ? (lay.pos[this.selected] ? this.selected : this.groupOf[this.selected] ?? null) : null;
|
|
181
200
|
const lit = litEdges(edges, shownSel);
|
|
201
|
+
const found = searchOf(this.query, nodes);
|
|
182
202
|
|
|
183
203
|
this.top.replaceChildren(this.#head(doc, findings), this.#toolbar(d), this.#source(d));
|
|
184
204
|
|
|
@@ -220,6 +240,7 @@ export class MapView {
|
|
|
220
240
|
if (shownSel) path.dataset.lit = String(lit.has(edgeKey(e)));
|
|
221
241
|
lines.append(path);
|
|
222
242
|
const tags = [];
|
|
243
|
+
if (EDGE_WORD[e.kind] && !e.calls) tags.push(mk('span', 'mp-badge', EDGE_WORD[e.kind]));
|
|
223
244
|
if (e.line === 'model') { const m = mk('span', 'mp-pill', 'model'); m.dataset.tone = 'model'; m.title = 'Proposed by the model, not shown by a file'; tags.push(m); }
|
|
224
245
|
if (e.calls) {
|
|
225
246
|
tags.push(mk('span', 'mp-pill', `${e.calls} ${e.calls === 1 ? 'call' : 'calls'}`));
|
|
@@ -233,10 +254,15 @@ export class MapView {
|
|
|
233
254
|
}
|
|
234
255
|
}
|
|
235
256
|
parts.push(lines, ...labels);
|
|
236
|
-
for (const n of nodes)
|
|
257
|
+
for (const n of nodes) {
|
|
258
|
+
const card = this.#card(n, doc, lay.pos[n.id], counts[n.id] ?? 0, chips[n.id], edges, shownSel);
|
|
259
|
+
if (this.query.trim()) card.dataset.match = String(found.has(n.id));
|
|
260
|
+
parts.push(card);
|
|
261
|
+
}
|
|
237
262
|
this.layer.replaceChildren(...parts);
|
|
238
263
|
if (!this.fitted) { this.fitted = true; this.fit(); } else this.vp.apply();
|
|
239
264
|
|
|
265
|
+
this.#tourBox(doc);
|
|
240
266
|
this.below.replaceChildren(...(this.layers.findings ? [this.#findings(findings)] : []));
|
|
241
267
|
this.#modList(all, counts);
|
|
242
268
|
const picked = this.selected ? all.find((n) => n.id === this.selected) ?? null : null;
|
|
@@ -261,6 +287,26 @@ export class MapView {
|
|
|
261
287
|
bar.append(b);
|
|
262
288
|
}
|
|
263
289
|
bar.append(mk('span', 'row-fill'));
|
|
290
|
+
const search = mk('input', 'mp-search');
|
|
291
|
+
search.type = 'search';
|
|
292
|
+
search.placeholder = 'Search files and modules';
|
|
293
|
+
search.setAttribute('aria-label', 'Search files and modules');
|
|
294
|
+
search.value = this.query;
|
|
295
|
+
search.addEventListener('input', () => { this.query = search.value; this.render(); this.top.querySelector('.mp-search')?.focus(); });
|
|
296
|
+
search.addEventListener('keydown', (e) => {
|
|
297
|
+
if (e.key !== 'Enter') return;
|
|
298
|
+
const first = [...searchOf(this.query, nodesOf(d.doc, d.compare))][0];
|
|
299
|
+
if (first) this.select(first, { reveal: true });
|
|
300
|
+
});
|
|
301
|
+
bar.append(search);
|
|
302
|
+
if (tourOf(d.doc).length) {
|
|
303
|
+
const t = mk('button', 'mp-tog', 'Tour');
|
|
304
|
+
t.type = 'button';
|
|
305
|
+
t.setAttribute('aria-pressed', String(this.tourAt >= 0));
|
|
306
|
+
t.title = 'The stops /crew-map wrote for someone new to the project, in order';
|
|
307
|
+
t.addEventListener('click', () => this.tour(this.tourAt >= 0 ? -1 : 0));
|
|
308
|
+
bar.append(t);
|
|
309
|
+
}
|
|
264
310
|
const pick = mk('select', 'mp-compare');
|
|
265
311
|
pick.setAttribute('aria-label', 'Compare with');
|
|
266
312
|
const none = mk('option', null, d.others?.length ? 'Compare with…' : 'Compare with: no other project has a map');
|
|
@@ -322,10 +368,29 @@ export class MapView {
|
|
|
322
368
|
if (!card.title) card.title = (n.hosts ?? []).join(', ');
|
|
323
369
|
} else {
|
|
324
370
|
const top = mk('span', 'mp-row');
|
|
325
|
-
|
|
371
|
+
const name = mk('span', 'mp-name', n.name ?? n.id);
|
|
372
|
+
name.title = n.name ?? n.id;
|
|
373
|
+
top.append(name);
|
|
326
374
|
if (count) { const f = mk('span', 'mp-fb', String(count)); f.title = `${count} ${count === 1 ? 'finding' : 'findings'}`; top.append(f); }
|
|
327
|
-
|
|
328
|
-
card.append(top, mk('span', 'sub',
|
|
375
|
+
top.append(mk('span', 'row-fill'));
|
|
376
|
+
if (n.kind === 'group') { card.append(top, mk('span', 'sub', `${n.members.length} modules · click to open`)); }
|
|
377
|
+
else if (n.kind !== 'module') { card.append(top, mk('span', 'sub', cardSub(n, doc))); }
|
|
378
|
+
else {
|
|
379
|
+
const c = cardOf(n, doc);
|
|
380
|
+
if (c.kind) top.append(mk('span', 'mp-kind', c.kind));
|
|
381
|
+
card.append(top);
|
|
382
|
+
if (c.chips.length) {
|
|
383
|
+
const row = mk('span', 'mp-chips');
|
|
384
|
+
for (const t of c.chips) row.append(mk('span', 'mp-chip', t));
|
|
385
|
+
if (c.more) { const m = mk('span', 'mp-chip', `+${c.more}`); m.title = (n.tech ?? []).slice(3).map((t) => t.replace(/@.*$/, '')).join(', '); row.append(m); }
|
|
386
|
+
card.append(row);
|
|
387
|
+
}
|
|
388
|
+
if (this.layers.external) {
|
|
389
|
+
const fold = mk('span', `mp-fold${c.outside.length ? '' : ' none'}`, c.outside.length ? `▸ ${c.outside.join(' · ')}` : 'no outside calls');
|
|
390
|
+
if (c.outside.length) fold.title = c.outside.join('\n');
|
|
391
|
+
card.append(fold);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
329
394
|
if (chips?.length) { const c = mk('span', 'mp-chips'); for (const t of chips) c.append(mk('span', 'mp-chip', t)); card.append(c); }
|
|
330
395
|
}
|
|
331
396
|
draggable(card, {
|
|
@@ -402,6 +467,12 @@ export class MapView {
|
|
|
402
467
|
close.addEventListener('click', () => this.select(null));
|
|
403
468
|
top.append(mk('span', 'mp-name', v.title), mk('span', 'row-fill'), close);
|
|
404
469
|
parts.push(top);
|
|
470
|
+
if (v.layer || v.domain) {
|
|
471
|
+
const pills = mk('div', 'mp-row');
|
|
472
|
+
if (v.layer) pills.append(mk('span', 'mp-pill', `Layer · ${v.layer}`));
|
|
473
|
+
if (v.domain) pills.append(mk('span', 'mp-pill', `Domain · ${v.domain}`));
|
|
474
|
+
parts.push(pills);
|
|
475
|
+
}
|
|
405
476
|
if (v.path) parts.push(mk('code', 'sub', v.path));
|
|
406
477
|
if (v.chips.length) { const c = mk('div', 'mp-chips'); for (const t of v.chips) c.append(mk('span', 'mp-chip', t)); parts.push(c); }
|
|
407
478
|
const tabs = mk('nav', 'mp-tabs');
|
|
@@ -417,6 +488,13 @@ export class MapView {
|
|
|
417
488
|
if (this.tab === 'overview') {
|
|
418
489
|
sec.append(mk('span', 'lbl', 'What it is for'), mk('p', v.narrative ? null : 'sub', v.narrative ?? v.narrativeNote));
|
|
419
490
|
if (v.narrative && v.narrativeCites.length) sec.append(mk('p', 'sub mp-cites', `From ${v.narrativeCites.join(', ')}`));
|
|
491
|
+
if (v.inside && (v.inside.entry || v.inside.folders.length)) {
|
|
492
|
+
sec.append(mk('span', 'lbl', 'Inside'));
|
|
493
|
+
if (v.inside.entry) { const r = mk('div', 'mp-conn mp-inside'); r.append(this.#cite(v.inside.entry), mk('span', 'mp-ev', 'entry point')); sec.append(r); }
|
|
494
|
+
for (const f of v.inside.folders) { const r = mk('div', 'mp-conn mp-inside'); r.append(mk('span', 'mp-file', `${f.folder === '.' ? '(root)' : `${f.folder}/`}`), mk('span', 'mp-ev', `${f.files} ${f.files === 1 ? 'file' : 'files'}`)); sec.append(r); }
|
|
495
|
+
if (v.inside.other) sec.append(mk('p', 'sub', `${v.inside.other} more files in other folders`));
|
|
496
|
+
if (v.inside.tests) sec.append(mk('p', 'sub', `${v.inside.tests} test ${v.inside.tests === 1 ? 'file' : 'files'}`));
|
|
497
|
+
}
|
|
420
498
|
sec.append(mk('span', 'lbl', 'Connects to'));
|
|
421
499
|
if (!v.connectsTo.length) sec.append(mk('p', 'sub', 'Nothing on this map.'));
|
|
422
500
|
for (const c of v.connectsTo) sec.append(this.#conn(c));
|
|
@@ -459,6 +537,21 @@ export class MapView {
|
|
|
459
537
|
this.side.replaceChildren(...parts);
|
|
460
538
|
}
|
|
461
539
|
|
|
540
|
+
/** The tour's stop, over the bottom of the pane: which of how many, what to look at, and the way on. */
|
|
541
|
+
#tourBox(doc) {
|
|
542
|
+
const stops = tourOf(doc);
|
|
543
|
+
if (this.tourAt < 0 || !stops.length) { this.tourBox.hidden = true; return; }
|
|
544
|
+
const s = stops[Math.min(this.tourAt, stops.length - 1)];
|
|
545
|
+
const head = mk('div', 'mp-row');
|
|
546
|
+
head.append(mk('span', 'lbl', `Tour · ${this.tourAt + 1} of ${stops.length}`), mk('span', 'mp-name', s.name), mk('span', 'row-fill'));
|
|
547
|
+
const nav = (label, at, off) => { const b = mk('button', 'btn sm ghost', label); b.type = 'button'; b.disabled = off; b.addEventListener('click', () => this.tour(at)); return b; };
|
|
548
|
+
head.append(nav('Back', this.tourAt - 1, this.tourAt === 0), nav(this.tourAt === stops.length - 1 ? 'Done' : 'Next', this.tourAt === stops.length - 1 ? -1 : this.tourAt + 1, false));
|
|
549
|
+
const body = [head, mk('p', null, s.text)];
|
|
550
|
+
if (s.cites.length) body.push(mk('p', 'sub mp-cites', `From ${s.cites.join(', ')}`));
|
|
551
|
+
this.tourBox.replaceChildren(...body);
|
|
552
|
+
this.tourBox.hidden = false;
|
|
553
|
+
}
|
|
554
|
+
|
|
462
555
|
/** A file:line the viewer can copy. */
|
|
463
556
|
#cite(text) {
|
|
464
557
|
const b = mk('button', 'mp-ev mp-cite', text);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// qr.js — a QR code for a viewer's link, made in the page: no dependency, no request.
|
|
2
|
+
//
|
|
3
|
+
// Byte mode, error correction level M (a phone reads it off a laptop screen at an angle), versions 1 to 10: up to
|
|
4
|
+
// 213 bytes, which a Wi-Fi link with a token (about 70) fits in with room to spare. The construction is ISO/IEC
|
|
5
|
+
// 18004's, in the order Project Nayuki's reference implementation lays it out: data and padding, Reed–Solomon error
|
|
6
|
+
// correction per block, interleaving, the function patterns, the codewords in their zigzag, the eight masks scored by
|
|
7
|
+
// the standard's four penalty rules, and the format (and, from version 7, version) bits. Checked against an
|
|
8
|
+
// independent decoder (OpenCV's QRCodeDetector) on every version and mask; the self-check pins the result.
|
|
9
|
+
|
|
10
|
+
const ECC_M = { perBlock: [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26], blocks: [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5], formatBits: 0 };
|
|
11
|
+
const MAX_VERSION = 10;
|
|
12
|
+
|
|
13
|
+
function rawModules(ver) {
|
|
14
|
+
let n = (16 * ver + 128) * ver + 64;
|
|
15
|
+
if (ver >= 2) {
|
|
16
|
+
const align = Math.floor(ver / 7) + 2;
|
|
17
|
+
n -= (25 * align - 10) * align - 55;
|
|
18
|
+
if (ver >= 7) n -= 36;
|
|
19
|
+
}
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
const dataCodewords = (ver) => Math.floor(rawModules(ver) / 8) - ECC_M.perBlock[ver] * ECC_M.blocks[ver];
|
|
23
|
+
|
|
24
|
+
// GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1.
|
|
25
|
+
function mul(x, y) {
|
|
26
|
+
let z = 0;
|
|
27
|
+
for (let i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); z ^= ((y >>> i) & 1) * x; }
|
|
28
|
+
return z & 0xff;
|
|
29
|
+
}
|
|
30
|
+
function divisor(degree) {
|
|
31
|
+
const r = new Array(degree).fill(0); r[degree - 1] = 1;
|
|
32
|
+
let root = 1;
|
|
33
|
+
for (let i = 0; i < degree; i++) {
|
|
34
|
+
for (let j = 0; j < r.length; j++) { r[j] = mul(r[j], root); if (j + 1 < r.length) r[j] ^= r[j + 1]; }
|
|
35
|
+
root = mul(root, 0x02);
|
|
36
|
+
}
|
|
37
|
+
return r;
|
|
38
|
+
}
|
|
39
|
+
function remainder(data, div) {
|
|
40
|
+
const r = div.map(() => 0);
|
|
41
|
+
for (const b of data) {
|
|
42
|
+
const f = b ^ r.shift(); r.push(0);
|
|
43
|
+
div.forEach((c, i) => { r[i] ^= mul(c, f); });
|
|
44
|
+
}
|
|
45
|
+
return r;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function alignmentPositions(ver) {
|
|
49
|
+
if (ver === 1) return [];
|
|
50
|
+
const n = Math.floor(ver / 7) + 2;
|
|
51
|
+
const size = ver * 4 + 17;
|
|
52
|
+
const step = Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
|
|
53
|
+
const out = [6];
|
|
54
|
+
for (let pos = size - 7; out.length < n; pos -= step) out.splice(1, 0, pos);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The bytes of `text` (UTF-8) as the codewords of the smallest version that holds them, error correction included. */
|
|
59
|
+
function codewords(bytes) {
|
|
60
|
+
let ver = 1;
|
|
61
|
+
for (; ver <= MAX_VERSION; ver++) {
|
|
62
|
+
const cap = dataCodewords(ver) * 8;
|
|
63
|
+
if (4 + (ver < 10 ? 8 : 16) + bytes.length * 8 <= cap) break;
|
|
64
|
+
}
|
|
65
|
+
if (ver > MAX_VERSION) throw new Error(`qr: ${bytes.length} bytes do not fit version ${MAX_VERSION}`);
|
|
66
|
+
const bits = [];
|
|
67
|
+
const put = (v, n) => { for (let i = n - 1; i >= 0; i--) bits.push((v >>> i) & 1); };
|
|
68
|
+
put(0b0100, 4); put(bytes.length, ver < 10 ? 8 : 16);
|
|
69
|
+
for (const b of bytes) put(b, 8);
|
|
70
|
+
const cap = dataCodewords(ver) * 8;
|
|
71
|
+
put(0, Math.min(4, cap - bits.length));
|
|
72
|
+
put(0, (8 - (bits.length % 8)) % 8);
|
|
73
|
+
for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8);
|
|
74
|
+
const data = [];
|
|
75
|
+
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
|
|
76
|
+
|
|
77
|
+
const nBlocks = ECC_M.blocks[ver]; const eccLen = ECC_M.perBlock[ver];
|
|
78
|
+
const raw = Math.floor(rawModules(ver) / 8);
|
|
79
|
+
const nShort = nBlocks - (raw % nBlocks); const shortLen = Math.floor(raw / nBlocks);
|
|
80
|
+
const div = divisor(eccLen);
|
|
81
|
+
const blocks = [];
|
|
82
|
+
for (let i = 0, k = 0; i < nBlocks; i++) {
|
|
83
|
+
const d = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1)); k += d.length;
|
|
84
|
+
const e = remainder(d, div);
|
|
85
|
+
if (i < nShort) d.push(0); // a placeholder, skipped when interleaving
|
|
86
|
+
blocks.push(d.concat(e));
|
|
87
|
+
}
|
|
88
|
+
const out = [];
|
|
89
|
+
for (let i = 0; i < blocks[0].length; i++) {
|
|
90
|
+
blocks.forEach((b, j) => { if (i !== shortLen - eccLen || j >= nShort) out.push(b[i]); });
|
|
91
|
+
}
|
|
92
|
+
return { ver, out };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MASKS = [
|
|
96
|
+
(x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
|
|
97
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
98
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The QR code for `text`: { size, modules } where modules[y][x] is true for a dark module, quiet zone not included.
|
|
103
|
+
* `mask` forces one of the eight masks (the self-check reads every one); otherwise the one the penalty rules pick.
|
|
104
|
+
*/
|
|
105
|
+
export function qr(text, { mask = null } = {}) {
|
|
106
|
+
const bytes = [...new TextEncoder().encode(String(text))];
|
|
107
|
+
const { ver, out } = codewords(bytes);
|
|
108
|
+
const size = ver * 4 + 17;
|
|
109
|
+
const m = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
110
|
+
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
111
|
+
const set = (x, y, dark) => { m[y][x] = dark; fn[y][x] = true; };
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
|
114
|
+
for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
|
|
115
|
+
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
|
|
116
|
+
const x = cx + dx; const y = cy + dy;
|
|
117
|
+
if (x >= 0 && x < size && y >= 0 && y < size) { const d = Math.max(Math.abs(dx), Math.abs(dy)); set(x, y, d !== 2 && d !== 4); }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const al = alignmentPositions(ver);
|
|
121
|
+
al.forEach((ax, i) => al.forEach((ay, j) => {
|
|
122
|
+
if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) return;
|
|
123
|
+
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
124
|
+
}));
|
|
125
|
+
const format = (mk) => {
|
|
126
|
+
const d = (ECC_M.formatBits << 3) | mk;
|
|
127
|
+
let r = d; for (let i = 0; i < 10; i++) r = (r << 1) ^ ((r >>> 9) * 0x537);
|
|
128
|
+
const b = ((d << 10) | r) ^ 0x5412; const bit = (i) => ((b >>> i) & 1) === 1;
|
|
129
|
+
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
|
|
130
|
+
set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8));
|
|
131
|
+
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
|
|
132
|
+
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
|
|
133
|
+
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
|
|
134
|
+
set(8, size - 8, true);
|
|
135
|
+
};
|
|
136
|
+
format(0); // reserves the format area before the data is drawn
|
|
137
|
+
if (ver >= 7) {
|
|
138
|
+
let r = ver; for (let i = 0; i < 12; i++) r = (r << 1) ^ ((r >>> 11) * 0x1f25);
|
|
139
|
+
const b = (ver << 12) | r;
|
|
140
|
+
for (let i = 0; i < 18; i++) { const dark = ((b >>> i) & 1) === 1; const a = size - 11 + (i % 3); const c = Math.floor(i / 3); set(a, c, dark); set(c, a, dark); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let i = 0;
|
|
144
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
145
|
+
if (right === 6) right = 5;
|
|
146
|
+
for (let v = 0; v < size; v++) for (let j = 0; j < 2; j++) {
|
|
147
|
+
const x = right - j; const up = ((right + 1) & 2) === 0; const y = up ? size - 1 - v : v;
|
|
148
|
+
if (!fn[y][x] && i < out.length * 8) { m[y][x] = ((out[i >>> 3] >>> (7 - (i & 7))) & 1) === 1; i++; }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const apply = (mk) => { for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && MASKS[mk](x, y)) m[y][x] = !m[y][x]; };
|
|
153
|
+
let chosen = mask;
|
|
154
|
+
if (chosen === null) {
|
|
155
|
+
let best = Infinity;
|
|
156
|
+
for (let mk = 0; mk < 8; mk++) {
|
|
157
|
+
apply(mk); format(mk);
|
|
158
|
+
const p = penalty(m, size);
|
|
159
|
+
if (p < best) { best = p; chosen = mk; }
|
|
160
|
+
apply(mk);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
apply(chosen); format(chosen);
|
|
164
|
+
return { size, version: ver, mask: chosen, modules: m };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The standard's four penalty rules: runs of five or more, 2x2 blocks, finder-like patterns, dark/light balance.
|
|
168
|
+
function penalty(m, size) {
|
|
169
|
+
let p = 0;
|
|
170
|
+
const line = (get) => {
|
|
171
|
+
let run = 0; let prev = null; const hist = [];
|
|
172
|
+
for (let i = 0; i < size; i++) {
|
|
173
|
+
const c = get(i);
|
|
174
|
+
if (c === prev) { run++; if (run === 5) p += 3; else if (run > 5) p += 1; } else { run = 1; prev = c; }
|
|
175
|
+
hist.push(c ? 1 : 0);
|
|
176
|
+
}
|
|
177
|
+
const s = hist.join('');
|
|
178
|
+
for (const pat of ['10111010000', '00001011101']) { let k = s.indexOf(pat); while (k !== -1) { p += 40; k = s.indexOf(pat, k + 1); } }
|
|
179
|
+
};
|
|
180
|
+
for (let y = 0; y < size; y++) line((x) => m[y][x]);
|
|
181
|
+
for (let x = 0; x < size; x++) line((y) => m[y][x]);
|
|
182
|
+
let dark = 0;
|
|
183
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
184
|
+
if (m[y][x]) dark++;
|
|
185
|
+
if (x < size - 1 && y < size - 1 && m[y][x] === m[y][x + 1] && m[y][x] === m[y + 1][x] && m[y][x] === m[y + 1][x + 1]) p += 3;
|
|
186
|
+
}
|
|
187
|
+
const total = size * size;
|
|
188
|
+
p += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10;
|
|
189
|
+
return p;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The code as an SVG string: one path and a quiet zone of four modules. Its two colours are tokens.css's
|
|
193
|
+
* --qr-dark and --qr-light, the same in both themes: a camera reads contrast, not the palette. */
|
|
194
|
+
export function qrSvg(text, { px = 4 } = {}) {
|
|
195
|
+
const { size, modules } = qr(text);
|
|
196
|
+
const q = 4; const n = size + q * 2;
|
|
197
|
+
let d = '';
|
|
198
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (modules[y][x]) d += `M${x + q} ${y + q}h1v1h-1z`;
|
|
199
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" width="${n * px}" height="${n * px}" shape-rendering="crispEdges" role="img" aria-label="QR code of the viewer link"><rect class="qr-light" width="${n}" height="${n}"/><path class="qr-dark" d="${d}"/></svg>`;
|
|
200
|
+
}
|
package/kit/studio/web/style.css
CHANGED
|
@@ -417,6 +417,19 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
|
|
|
417
417
|
.rv-give .rv-say { padding: 0; }
|
|
418
418
|
.rv-url { font-size: 12px; line-height: 16px; overflow-wrap: anywhere; user-select: all; color: var(--ink); }
|
|
419
419
|
.rv-acts { display: flex; gap: var(--space-2); }
|
|
420
|
+
/* The QR code is dark on white whatever the theme: a phone's camera reads contrast, not the palette. */
|
|
421
|
+
.rv-qr { align-self: center; line-height: 0; border-radius: var(--radius-md); overflow: hidden; }
|
|
422
|
+
.rv-qr svg { display: block; width: 168px; height: auto; max-width: 100%; }
|
|
423
|
+
.rv-qr .qr-light { fill: var(--qr-light); }
|
|
424
|
+
.rv-qr .qr-dark { fill: var(--qr-dark); }
|
|
425
|
+
.rv-opt { display: flex; align-items: center; gap: var(--space-1); padding: var(--space-1); font-size: 12px; color: var(--ink-dim); }
|
|
426
|
+
/* A viewer link past its eight hours: the page is covered, with what happened and what to do. */
|
|
427
|
+
.expired {
|
|
428
|
+
position: fixed; inset: 0; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
429
|
+
gap: var(--space-2); padding: var(--space-4); text-align: center; background: var(--surface); color: var(--ink);
|
|
430
|
+
}
|
|
431
|
+
.expired h1 { margin: 0; font-size: 20px; }
|
|
432
|
+
.expired p { margin: 0; max-width: 34ch; color: var(--ink-dim); }
|
|
420
433
|
/* A viewer's page offers no way to start a session: the server refuses it, and a button that only fails is noise. */
|
|
421
434
|
body[data-role="viewer"] #new-session { display: none; }
|
|
422
435
|
|
|
@@ -666,6 +679,25 @@ body[data-role="viewer"] #new-session { display: none; }
|
|
|
666
679
|
.mp-cite:hover { color: var(--ink); text-decoration: underline; }
|
|
667
680
|
.mp-pill[data-tone="model"] { color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 14%, transparent); }
|
|
668
681
|
/* A phone shows the findings and a list of the modules in place of the pane (spec §11). */
|
|
682
|
+
/* CM3: a module card names itself in full (the kind line gives way), carries what it is built with, and folds the
|
|
683
|
+
outside services it calls into one line under it. A search dims what it does not name; the tour sits over the
|
|
684
|
+
bottom of the pane. */
|
|
685
|
+
.mp-mod { gap: 6px; }
|
|
686
|
+
.mp-mod .mp-row .mp-name { flex: none; overflow: visible; }
|
|
687
|
+
.mp-mod .mp-row .mp-kind { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
688
|
+
.mp-fold { font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
689
|
+
.mp-fold.none { color: var(--ink-faint); }
|
|
690
|
+
.mp-card[data-match="false"] { opacity: 0.3; }
|
|
691
|
+
.mp-card[data-match="true"] { border-color: var(--studio-accent); }
|
|
692
|
+
.mp-search { height: 30px; min-width: 200px; padding: 0 10px; border-radius: var(--radius-md); border: 1px solid var(--line); background: var(--surface); color: var(--ink); font: inherit; font-size: 12px; }
|
|
693
|
+
.mp-search::placeholder { color: var(--ink-dim); }
|
|
694
|
+
.mp-tour { position: absolute; left: 16px; right: 16px; bottom: 14px; z-index: 6; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--studio-accent); border-radius: var(--radius-md); background: var(--surface-raised); cursor: default; }
|
|
695
|
+
.mp-tour p { margin: 0; font-size: 14px; line-height: 21px; }
|
|
696
|
+
.mp-tour[hidden] { display: none; }
|
|
697
|
+
.mp-inside { flex-direction: row; align-items: center; gap: 10px; }
|
|
698
|
+
.mp-inside .mp-ev { margin-left: auto; }
|
|
699
|
+
.mp-inside .mp-cite, .mp-inside .mp-file { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
700
|
+
.mp-inside .mp-ev:last-child { flex: none; }
|
|
669
701
|
.mp-modlist { display: none; flex-direction: column; gap: 2px; padding: 10px 14px; border-top: 1px solid var(--line); }
|
|
670
702
|
.mp-mrow { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--ink); font: inherit; text-align: left; cursor: pointer; }
|
|
671
703
|
.mp-mrow:hover { background: var(--surface-raised); }
|
|
@@ -1210,6 +1242,8 @@ button.tl-lab { cursor: pointer; }
|
|
|
1210
1242
|
and do not shrink. */
|
|
1211
1243
|
.cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
1212
1244
|
.cv-sub[hidden] { display: none; }
|
|
1245
|
+
/* Agent calls the model gate refused: no agent started, so this line is the only trace on the canvas. */
|
|
1246
|
+
.cv-refused { color: var(--status-waiting); }
|
|
1213
1247
|
.cv-node > .cv-row, .cv-node > .cv-under, .cv-node > .cv-name, .cv-node > .cv-sub { max-width: 100%; }
|
|
1214
1248
|
.cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
|
|
1215
1249
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
--ink: #e6e9f0;
|
|
11
11
|
--ink-dim: #9aa7cc;
|
|
12
12
|
--ink-faint: #5c6472;
|
|
13
|
+
--qr-dark: #000000;
|
|
14
|
+
--qr-light: #ffffff;
|
|
13
15
|
--accent: #A78BFA;
|
|
14
16
|
--on-accent: #0B1020;
|
|
15
17
|
--wordmark-ink: #FFFFFF;
|
|
@@ -86,6 +88,8 @@
|
|
|
86
88
|
--ink: #1a1d24;
|
|
87
89
|
--ink-dim: #5c6472;
|
|
88
90
|
--ink-faint: #8d95a3;
|
|
91
|
+
--qr-dark: #000000;
|
|
92
|
+
--qr-light: #ffffff;
|
|
89
93
|
--accent: #6D28D9;
|
|
90
94
|
--on-accent: #ffffff;
|
|
91
95
|
--wordmark-ink: #0F172A;
|
|
@@ -113,6 +117,8 @@
|
|
|
113
117
|
--ink: #1a1d24;
|
|
114
118
|
--ink-dim: #5c6472;
|
|
115
119
|
--ink-faint: #8d95a3;
|
|
120
|
+
--qr-dark: #000000;
|
|
121
|
+
--qr-light: #ffffff;
|
|
116
122
|
--accent: #6D28D9;
|
|
117
123
|
--on-accent: #ffffff;
|
|
118
124
|
--wordmark-ink: #0F172A;
|
package/package.json
CHANGED