@squinch/core 0.1.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 (64) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +41 -0
  3. package/dist/api.d.ts +91 -0
  4. package/dist/api.js +232 -0
  5. package/dist/browser.d.ts +3 -0
  6. package/dist/browser.js +9 -0
  7. package/dist/diff/diff.d.ts +30 -0
  8. package/dist/diff/diff.js +365 -0
  9. package/dist/fonts.generated.d.ts +1 -0
  10. package/dist/fonts.generated.js +6 -0
  11. package/dist/grammar/parser.js +22 -0
  12. package/dist/grammar/parser.terms.js +115 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +4 -0
  15. package/dist/layout/layout.d.ts +197 -0
  16. package/dist/layout/layout.js +1721 -0
  17. package/dist/metrics.d.ts +20 -0
  18. package/dist/metrics.generated.d.ts +4 -0
  19. package/dist/metrics.generated.js +4 -0
  20. package/dist/metrics.js +57 -0
  21. package/dist/model/build.d.ts +8 -0
  22. package/dist/model/build.js +1343 -0
  23. package/dist/model/packs.d.ts +13 -0
  24. package/dist/model/packs.js +29 -0
  25. package/dist/model/source.d.ts +10 -0
  26. package/dist/model/source.js +28 -0
  27. package/dist/model/suggest.d.ts +2 -0
  28. package/dist/model/suggest.js +24 -0
  29. package/dist/model/types.d.ts +226 -0
  30. package/dist/model/types.js +24 -0
  31. package/dist/packs/node-fs.d.ts +1 -0
  32. package/dist/packs/node-fs.js +37 -0
  33. package/dist/packs/registry.d.ts +61 -0
  34. package/dist/packs/registry.js +122 -0
  35. package/dist/packs/sanitize.d.ts +12 -0
  36. package/dist/packs/sanitize.js +127 -0
  37. package/dist/packs/sysGlyphs.d.ts +2 -0
  38. package/dist/packs/sysGlyphs.js +21 -0
  39. package/dist/render/adaptive.d.ts +13 -0
  40. package/dist/render/adaptive.js +112 -0
  41. package/dist/render/html/runtime.d.ts +1 -0
  42. package/dist/render/html/runtime.generated.d.ts +1 -0
  43. package/dist/render/html/runtime.generated.js +6 -0
  44. package/dist/render/html/runtime.js +362 -0
  45. package/dist/render/html.d.ts +39 -0
  46. package/dist/render/html.js +235 -0
  47. package/dist/render/svg.d.ts +75 -0
  48. package/dist/render/svg.js +1403 -0
  49. package/dist/render/validate.d.ts +4 -0
  50. package/dist/render/validate.js +9 -0
  51. package/dist/themes/index.d.ts +84 -0
  52. package/dist/themes/index.js +90 -0
  53. package/dist/view/dive.d.ts +55 -0
  54. package/dist/view/dive.js +57 -0
  55. package/dist/view/navigate.d.ts +38 -0
  56. package/dist/view/navigate.js +81 -0
  57. package/dist/view/resolve.d.ts +92 -0
  58. package/dist/view/resolve.js +591 -0
  59. package/fonts/inter-400.ttf +0 -0
  60. package/fonts/inter-500.ttf +0 -0
  61. package/fonts/inter-600.ttf +0 -0
  62. package/fonts/mono-400.ttf +0 -0
  63. package/metrics.json +510 -0
  64. package/package.json +89 -0
@@ -0,0 +1,362 @@
1
+ // The viewer that ships inside an exported diagram.
2
+ //
3
+ // This is the one place in the project where we ship JavaScript to a reader.
4
+ // It is a *sibling* of the SVGs, never inside one: every embedded SVG in the
5
+ // document is byte-comparable to `squinch render -o x.svg`, and the entry view
6
+ // is inline in the markup, so a reader whose browser (or wiki sanitizer) drops
7
+ // this script still sees a correct static diagram. That is the whole shape of
8
+ // the exception recorded in CLAUDE.md.
9
+ //
10
+ // It imports `../../view/dive.js` and `../../view/navigate.js` directly and is
11
+ // bundled by `scripts/gen-html-runtime.ts`, which is what makes "the export
12
+ // performs the same motion as the playground" a fact about the build rather
13
+ // than a comment nobody can check.
14
+ import { diveTransforms } from "../../view/dive.js";
15
+ import { hop, upView, viewForPath } from "../../view/navigate.js";
16
+ const $ = (sel) => document.querySelector(sel);
17
+ function boot() {
18
+ const data = JSON.parse($("#sq-data").textContent || "{}");
19
+ const live = $("#sq-live");
20
+ const ghost = $("#sq-ghost");
21
+ const stage = $("#sq-stage");
22
+ const reduced = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
23
+ let view = data.entry;
24
+ let theme = data.themes[0];
25
+ let step = 0;
26
+ let presenting = false;
27
+ // The reader's own preference wins over the author's, when the file carries
28
+ // a palette that matches it — the same rule an adaptive SVG follows.
29
+ if (data.themes.length > 1 && matchMedia("(prefers-color-scheme: dark)").matches) {
30
+ const dark = data.themes.find((t) => t.includes("dark"));
31
+ if (dark)
32
+ theme = dark;
33
+ }
34
+ // The entry view is inline rather than in a <template>, so that a reader
35
+ // whose browser never runs this script still sees a diagram. That means it is
36
+ // the one body with no template to clone from — captured here at boot, before
37
+ // anything can replace it, or switching *back* to the entry palette would
38
+ // silently find nothing.
39
+ const inlineKey = `${data.entry}|${data.themes[0]}`;
40
+ const inlineBody = live.firstElementChild?.cloneNode(true) ?? null;
41
+ /** `step` 0 is the whole flow at once — the authoring frame, and what a view
42
+ * without a flow always shows. 1..N are the walked ones. */
43
+ const bodyFor = (v, th, step = 0) => {
44
+ const key = step ? `${v}|${th}|${step}` : `${v}|${th}`;
45
+ if (key === inlineKey)
46
+ return inlineBody?.cloneNode(true) ?? null;
47
+ const tpl = document.querySelector(`template[data-key="${CSS.escape(key)}"]`);
48
+ return tpl ? tpl.content.cloneNode(true) : null;
49
+ };
50
+ const scopeOf = (v) => data.views.find((x) => x.name === v)?.scope;
51
+ const boxOf = (el) => {
52
+ const r = el.getBoundingClientRect(), s = stage.getBoundingClientRect();
53
+ return { x: r.left - s.left, y: r.top - s.top, w: r.width, h: r.height };
54
+ };
55
+ function paintChrome() {
56
+ document.title = data.views.find((v) => v.name === view)?.title ?? document.title;
57
+ // The view tabs are the deck's one navigation surface — every view by
58
+ // name, the active one on a plate, exactly the SPA's picker. They replaced
59
+ // both the breadcrumb and the dots: the tabs already name where you are
60
+ // and where you can go, so two more spellings of the same facts were
61
+ // chrome without capability.
62
+ const tabs = document.querySelector("#sq-tabs");
63
+ if (tabs) {
64
+ tabs.replaceChildren();
65
+ for (const v of data.views) {
66
+ const b = document.createElement("button");
67
+ b.type = "button";
68
+ b.className = v.name === view ? "on" : "";
69
+ b.textContent = v.name;
70
+ b.title = v.title ?? v.name;
71
+ b.onclick = () => go(v.name);
72
+ tabs.append(b);
73
+ }
74
+ // keep the active tab reachable when the deck outgrows the bar
75
+ tabs.querySelector(".on")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
76
+ }
77
+ // Mark the cards that actually lead somewhere. Only the runtime can know:
78
+ // it depends on which views this file carries and which one you are in.
79
+ // Without it every card wore a zoom cursor and the ones with no view were
80
+ // a dead click — the bug this whole pass came from.
81
+ for (const el of live.querySelectorAll("[data-path]")) {
82
+ const p = el.getAttribute("data-path");
83
+ el.classList.toggle("sq-zoom", !!(p && viewForPath(data.views, view, p)));
84
+ }
85
+ const counter = document.querySelector("#sq-step");
86
+ const hops = data.flows[view] ?? 0;
87
+ if (counter)
88
+ counter.textContent = presenting && hops ? `${step || 1} / ${hops}` : "";
89
+ }
90
+ /** Swap the body, then animate the two layers about the card they share.
91
+ * The playground splits this in two ("arm, then fire") because compiling the
92
+ * next view is async; here every body is already in the document, so the
93
+ * swap is synchronous and the whole dance is one function. */
94
+ function go(target, enterAtEnd = false) {
95
+ if (!target || target === view)
96
+ return;
97
+ // A slide opens on its first hop — unless you reversed into it, in which
98
+ // case you arrive where you left and can keep unwinding.
99
+ const want = presenting && data.flows[target] ? (enterAtEnd ? data.flows[target] : 1) : 0;
100
+ const next = bodyFor(target, theme, want);
101
+ if (!next)
102
+ return;
103
+ step = want;
104
+ const { dir, anchor: anchorPath } = hop(data.views, view, target);
105
+ const ghostBox = boxOf(live);
106
+ const prev = live.firstElementChild;
107
+ if (reduced() || !prev) {
108
+ live.replaceChildren(next);
109
+ view = target;
110
+ paintChrome();
111
+ return;
112
+ }
113
+ ghost.replaceChildren(prev.cloneNode(true));
114
+ ghost.style.cssText =
115
+ `position:absolute;left:${ghostBox.x}px;top:${ghostBox.y}px;` +
116
+ `width:${ghostBox.w}px;height:${ghostBox.h}px;z-index:1;pointer-events:none`;
117
+ live.replaceChildren(next);
118
+ view = target;
119
+ paintChrome();
120
+ const liveBox = boxOf(live);
121
+ // Going down, the shared card is in the layer we just left; coming up it is
122
+ // in the one that just arrived. Both layers are on screen either way, which
123
+ // is why one lookup covers both.
124
+ const from = dir === "in" ? ghost : live;
125
+ const el = anchorPath ? from.querySelector(`[data-path="${CSS.escape(anchorPath)}"]`) : null;
126
+ const t = diveTransforms({
127
+ view: { x: 0, y: 0, w: stage.clientWidth, h: stage.clientHeight },
128
+ ghostBox, liveBox, anchor: el ? boxOf(el) : undefined, dir,
129
+ });
130
+ const g = ghost.style, l = live.style;
131
+ g.transition = "none";
132
+ g.transformOrigin = t.gOrigin;
133
+ g.transform = "none";
134
+ g.opacity = "1";
135
+ l.transition = "none";
136
+ l.transformOrigin = t.lOrigin;
137
+ l.transform = t.lStart;
138
+ l.opacity = "0";
139
+ void live.offsetHeight; // commit the start state before transitioning off it
140
+ g.transition = `transform ${t.ms}ms ${t.ease}, opacity ${Math.round(t.ms * 0.55)}ms ${t.ease}`;
141
+ l.transition =
142
+ `transform ${t.ms}ms ${t.ease}, opacity ${Math.round(t.ms * 0.6)}ms ${t.ease} ${Math.round(t.ms * 0.25)}ms`;
143
+ g.transform = t.gEnd;
144
+ g.opacity = "0";
145
+ l.transform = "none";
146
+ l.opacity = "1";
147
+ // Clean up when the dive actually lands, not on a wall-clock guess. The
148
+ // old timer fired t.ms after the *click*, but the transition cannot start
149
+ // until the browser paints the swapped-in body — a heavy first layout of
150
+ // a large view delayed that by hundreds of ms, the timer stripped the
151
+ // transition mid-flight, and the diagram visibly snapped into place a
152
+ // beat after the zoom. Watch the observable fact instead of an event:
153
+ // live's computed transform reads as interpolated matrices while the
154
+ // transition runs and becomes the literal "none" only when it has truly
155
+ // finished — one cheap read per frame for under half a second, immune to
156
+ // the transitionend quirks headless engines showed when this was
157
+ // event-driven. The timer is only a net for a hidden tab, where rAF
158
+ // stops; by then the motion is long over and cleanup is invisible.
159
+ let settled = false;
160
+ let raf = 0;
161
+ const settle = () => {
162
+ if (settled)
163
+ return;
164
+ settled = true;
165
+ cancelAnimationFrame(raf);
166
+ ghost.replaceChildren();
167
+ ghost.removeAttribute("style");
168
+ live.removeAttribute("style");
169
+ };
170
+ const watch = () => {
171
+ if (getComputedStyle(live).transform === "none")
172
+ settle();
173
+ else
174
+ raf = requestAnimationFrame(watch);
175
+ };
176
+ raf = requestAnimationFrame(watch);
177
+ setTimeout(settle, t.ms + 1000);
178
+ }
179
+ function setTheme(name) {
180
+ const body = name === theme ? null : bodyFor(view, name, step);
181
+ if (!body)
182
+ return;
183
+ theme = name;
184
+ document.documentElement.dataset.theme = name;
185
+ live.replaceChildren(body);
186
+ }
187
+ /** Walking a flow and walking the deck are one axis: keep stepping hops
188
+ * until the story runs out, then move to the next view. The playground
189
+ * merges them the same way, and for the same reason — one arrow key tells
190
+ * the whole story rather than two that each tell half. */
191
+ function step_(by) {
192
+ const hops = data.flows[view] ?? 0;
193
+ const next = step + by;
194
+ if (hops && next >= 1 && next <= hops) {
195
+ const body = bodyFor(view, theme, next);
196
+ if (body) {
197
+ step = next;
198
+ live.replaceChildren(body);
199
+ paintChrome();
200
+ }
201
+ return;
202
+ }
203
+ const at = data.views.findIndex((v) => v.name === view);
204
+ const to = data.views[at + by];
205
+ if (to)
206
+ go(to.name, by < 0);
207
+ }
208
+ live.addEventListener("click", (e) => {
209
+ const el = e.target.closest?.("[data-path]");
210
+ const path = el?.getAttribute("data-path");
211
+ if (path) {
212
+ const target = viewForPath(data.views, view, path);
213
+ if (target)
214
+ return go(target.name);
215
+ }
216
+ // clicking the canvas itself climbs, which is the gesture the playground
217
+ // gives the backdrop
218
+ if (!path) {
219
+ const up = upView(data.views, view, scopeOf(view));
220
+ if (up)
221
+ go(up);
222
+ }
223
+ });
224
+ const themeBtn = document.querySelector("#sq-theme");
225
+ if (themeBtn)
226
+ themeBtn.onclick = () => setTheme(data.themes[(data.themes.indexOf(theme) + 1) % data.themes.length]);
227
+ /** Full-bleed deck. The declared views in declaration order *are* the slides —
228
+ * nothing is authored twice (DESIGN §11). Fullscreen is requested on the
229
+ * gesture that turns it on, which is also the only time a browser allows it;
230
+ * the playground can ask on mount because entering is already a gesture
231
+ * there, but a file opens as a document. */
232
+ function present(on) {
233
+ if (on === presenting)
234
+ return;
235
+ presenting = on;
236
+ document.body.classList.toggle("presenting", on);
237
+ if (on) {
238
+ document.documentElement.requestFullscreen?.().catch(() => { });
239
+ const hops = data.flows[view] ?? 0;
240
+ if (hops && !step) {
241
+ const body = bodyFor(view, theme, 1);
242
+ if (body) {
243
+ step = 1;
244
+ live.replaceChildren(body);
245
+ }
246
+ }
247
+ }
248
+ else {
249
+ if (document.fullscreenElement)
250
+ document.exitFullscreen?.().catch(() => { });
251
+ if (step) {
252
+ const body = bodyFor(view, theme, 0);
253
+ if (body) {
254
+ step = 0;
255
+ live.replaceChildren(body);
256
+ }
257
+ }
258
+ }
259
+ paintChrome();
260
+ }
261
+ // Chrome gets out of the way while nothing is happening, and comes back on
262
+ // the first movement — a deck that keeps its furniture on screen is a
263
+ // screenshot of an app, not a presentation.
264
+ let idle;
265
+ const wake = () => {
266
+ document.body.classList.remove("idle");
267
+ clearTimeout(idle);
268
+ if (presenting)
269
+ idle = setTimeout(() => document.body.classList.add("idle"), 3500);
270
+ };
271
+ addEventListener("mousemove", wake);
272
+ addEventListener("keydown", wake);
273
+ const cycleTheme = () => setTheme(data.themes[(data.themes.indexOf(theme) + 1) % data.themes.length]);
274
+ addEventListener("keydown", (e) => {
275
+ if (e.metaKey || e.ctrlKey || e.altKey)
276
+ return;
277
+ switch (e.key) {
278
+ case "ArrowRight":
279
+ case "PageDown":
280
+ case " ":
281
+ case "Enter":
282
+ e.preventDefault();
283
+ step_(1);
284
+ break;
285
+ case "ArrowLeft":
286
+ case "PageUp":
287
+ e.preventDefault();
288
+ step_(-1);
289
+ break;
290
+ // climbing an altitude is a *different* move from stepping the deck, and
291
+ // gets its own keys
292
+ case "ArrowUp":
293
+ case "Backspace": {
294
+ const up = upView(data.views, view, scopeOf(view));
295
+ if (up) {
296
+ e.preventDefault();
297
+ go(up);
298
+ }
299
+ break;
300
+ }
301
+ case "Home":
302
+ e.preventDefault();
303
+ go(data.views[0].name);
304
+ break;
305
+ case "End":
306
+ e.preventDefault();
307
+ go(data.views[data.views.length - 1].name, true);
308
+ break;
309
+ case "Escape":
310
+ if (presenting) {
311
+ e.preventDefault();
312
+ present(false);
313
+ }
314
+ break;
315
+ case "p":
316
+ case "P":
317
+ present(!presenting);
318
+ break;
319
+ case "f":
320
+ case "F":
321
+ if (document.fullscreenElement)
322
+ document.exitFullscreen?.().catch(() => { });
323
+ else
324
+ document.documentElement.requestFullscreen?.().catch(() => { });
325
+ break;
326
+ case "t":
327
+ case "T":
328
+ if (data.themes.length > 1)
329
+ cycleTheme();
330
+ break;
331
+ }
332
+ });
333
+ // leaving fullscreen by the browser's own affordance should leave the deck
334
+ addEventListener("fullscreenchange", () => {
335
+ if (!document.fullscreenElement && presenting)
336
+ present(false);
337
+ });
338
+ const presentBtn = document.querySelector("#sq-present");
339
+ if (presentBtn)
340
+ presentBtn.onclick = () => present(!presenting);
341
+ // A deep link opens on that view — what makes the file shareable by more than
342
+ // its filename. Kept in sync so the reader's back button and a copied URL
343
+ // both do what they look like they do.
344
+ const fromHash = () => {
345
+ const want = decodeURIComponent(location.hash.slice(1));
346
+ if (want && want !== view && data.views.some((v) => v.name === want))
347
+ go(want);
348
+ };
349
+ addEventListener("hashchange", fromHash);
350
+ document.documentElement.dataset.theme = theme;
351
+ if (theme !== data.themes[0]) {
352
+ const body = bodyFor(view, theme);
353
+ if (body)
354
+ live.replaceChildren(body);
355
+ }
356
+ paintChrome();
357
+ fromHash();
358
+ }
359
+ if (document.readyState === "loading")
360
+ addEventListener("DOMContentLoaded", boot);
361
+ else
362
+ boot();
@@ -0,0 +1,39 @@
1
+ import { type ProjectFile } from "../model/build.js";
2
+ import type { Diagnostic } from "../model/types.js";
3
+ export interface HTMLExportOpts {
4
+ /** Which view opens. Default: the first declared one. */
5
+ view?: string;
6
+ /** Palettes to bundle; the first is the entry. Default: the project's theme
7
+ * and its `pairsWith` counterpart, so the file follows the reader's OS. */
8
+ themes?: string[];
9
+ /** `all` (default) includes the auto view every container gets, so every card
10
+ * that zooms in the playground zooms here too. `declared` trims it to the
11
+ * views an author wrote, which is smaller but leaves any container without
12
+ * one as a dead card — it was the default until a `Storefront` card in
13
+ * `examples/microservices` did nothing when clicked, because it is the one
14
+ * system there with no declared view of its own. `--sync` makes the opposite
15
+ * call for a good reason that does not apply here: an auto view costs it a
16
+ * *file* nobody asked for, where it costs this a few KB. */
17
+ views?: "declared" | "all";
18
+ /** Document title. Default: the entry view's title, or the project name. */
19
+ title?: string;
20
+ /** Pre-render one frame per hop of a `show flow`, so presentation mode can
21
+ * walk the story. Default true; only views that declare a flow cost
22
+ * anything, and a frame is the same 3–9 KB as any other body. */
23
+ flowSteps?: boolean;
24
+ }
25
+ export interface HTMLExportResult {
26
+ html?: string;
27
+ diagnostics: Diagnostic[];
28
+ ok: boolean;
29
+ /** What went in — the CLI prints it, the tests assert on it. */
30
+ manifest: {
31
+ views: string[];
32
+ themes: string[];
33
+ renders: number;
34
+ bytes: number;
35
+ /** view name → hops that render at that altitude, for the ones with a flow */
36
+ flows: Record<string, number>;
37
+ };
38
+ }
39
+ export declare function exportHTML(files: ProjectFile[], opts?: HTMLExportOpts): Promise<HTMLExportResult>;
@@ -0,0 +1,235 @@
1
+ // One self-contained HTML file carrying every view of a project, and a viewer
2
+ // that dives between them.
3
+ //
4
+ // Squinch's whole idea is that views are altitudes over one model and that
5
+ // moving between them is navigation (DESIGN §11). Until this, that experience
6
+ // existed only in the playground: everything you could hand someone else — an
7
+ // SVG, a PNG, an adaptive pair — was one frozen altitude. This is the artifact
8
+ // that travels.
9
+ //
10
+ // THE EXCEPTION, STATED. CLAUDE.md's rule is that an exported *SVG* never
11
+ // contains JS. That still holds without qualification, including for the SVGs
12
+ // in here: each one is what `render -o x.svg` produces (minus the defs this
13
+ // document shares), and the script is their sibling, never their content. The
14
+ // entry view is inline rather than in a <template>, so a reader whose browser
15
+ // or wiki sanitizer drops the script still gets a correct static diagram.
16
+ //
17
+ // WHY IT IS SMALL. Every view repeats the same two things: ~33 KB of base64
18
+ // font and 17–27 KB of icon <symbol>s. Fragment references resolve
19
+ // document-wide in HTML, so one definition serves all of them — `collectDefs`
20
+ // hands them out instead of emitting them, and they land once in a hidden
21
+ // sprite. Measured on `examples/microservices`: six views go from 362 KB
22
+ // concatenated to ~105 KB. The actual drawing is 3–9 KB per view, which is the
23
+ // right shape for the file to have.
24
+ import { buildProject } from "../model/build.js";
25
+ import { themes } from "../themes/index.js";
26
+ import { fontFaceCSS, allFaces } from "./svg.js";
27
+ import { RUNTIME_JS } from "./html/runtime.generated.js";
28
+ const EMPTY = { views: [], themes: [], renders: 0, bytes: 0, flows: {} };
29
+ /** `</script>` inside a JSON island would end the island. Escaping the `<`
30
+ * keeps the payload inert wherever it lands. */
31
+ const jsonIsland = (v) => JSON.stringify(v).replace(/</g, "\\u003c");
32
+ const attr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
33
+ const text = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
34
+ export async function exportHTML(files, opts = {}) {
35
+ // imported lazily: html.ts is part of the render layer and api.ts imports it,
36
+ // so taking renderProject the other way round would be a cycle
37
+ const { renderProject } = await import("../api.js");
38
+ const built = buildProject(files);
39
+ if (!built.ok)
40
+ return { diagnostics: built.diagnostics, ok: false, manifest: EMPTY };
41
+ const all = built.model.views;
42
+ const list = (opts.views === "declared" ? all.filter((v) => !v.auto) : all).map((v) => ({ name: v.name, scope: v.scope, title: v.title, auto: v.auto }));
43
+ if (!list.length)
44
+ return {
45
+ diagnostics: [...built.diagnostics, {
46
+ severity: "error",
47
+ message: "nothing to export — this project declares no views",
48
+ fix: "add a `view` block, or pass views: \"all\" to include the automatic ones",
49
+ loc: { from: 0, to: 0, line: 1, col: 1 },
50
+ }],
51
+ ok: false,
52
+ manifest: EMPTY,
53
+ };
54
+ const entry = opts.view ?? list[0].name;
55
+ if (!list.some((v) => v.name === entry))
56
+ return {
57
+ diagnostics: [...built.diagnostics, {
58
+ severity: "error",
59
+ message: `unknown view \`${entry}\``,
60
+ fix: `views in this export: ${list.map((v) => v.name).join(", ")}`,
61
+ loc: { from: 0, to: 0, line: 1, col: 1 },
62
+ }],
63
+ ok: false,
64
+ manifest: EMPTY,
65
+ };
66
+ // A document has one palette at a time and a button to change it, so the
67
+ // theme is the document's rather than each view's — a per-view `theme` is
68
+ // deliberately overridden here, the way `--theme` overrides it elsewhere.
69
+ const base = opts.themes?.[0] ?? built.model.fileTheme ?? "light";
70
+ const palette = opts.themes ?? [base, themes[base]?.pairsWith].filter(Boolean);
71
+ for (const name of palette)
72
+ if (!themes[name])
73
+ return {
74
+ diagnostics: [...built.diagnostics, {
75
+ severity: "error",
76
+ message: `unknown theme \`${name}\``,
77
+ fix: `available: ${Object.keys(themes).join(", ")}`,
78
+ loc: { from: 0, to: 0, line: 1, col: 1 },
79
+ }],
80
+ ok: false,
81
+ manifest: EMPTY,
82
+ };
83
+ const defs = new Map();
84
+ const bodies = new Map();
85
+ const flows = {};
86
+ const diagnostics = [...built.diagnostics];
87
+ const draw = async (view, th, flowStep) => {
88
+ const r = await renderProject(files, {
89
+ view, theme: th, embedFonts: false, collectDefs: defs,
90
+ // the one theme-dependent def (`sq-hatch`) needs a distinct id per
91
+ // palette, or a dark view would draw with the light texture
92
+ defsScope: `-${th}`,
93
+ ...(flowStep === undefined ? {} : { flowStep }),
94
+ });
95
+ diagnostics.push(...r.diagnostics.filter((d) => d.severity === "error"));
96
+ return r;
97
+ };
98
+ for (const th of palette)
99
+ for (const v of list) {
100
+ // the whole flow at once is the authoring frame, and the one the file
101
+ // opens on; the walked frames are extra
102
+ const r = await draw(v.name, th);
103
+ if (!r.ok || !r.svg)
104
+ return { diagnostics, ok: false, manifest: EMPTY };
105
+ bodies.set(`${v.name}|${th}`, r.svg.trim());
106
+ // `flow.steps` counts hops that render *at this altitude*, which is
107
+ // exactly the number of frames a presenter can reach
108
+ if (!r.flow || opts.flowSteps === false)
109
+ continue;
110
+ flows[v.name] = r.flow.steps;
111
+ for (let step = 1; step <= r.flow.steps; step++) {
112
+ const f = await draw(v.name, th, step);
113
+ if (!f.ok || !f.svg)
114
+ return { diagnostics, ok: false, manifest: EMPTY };
115
+ bodies.set(`${v.name}|${th}|${step}`, f.svg.trim());
116
+ }
117
+ }
118
+ // Every face, not the union of what these views happened to draw: the
119
+ // viewer swaps views (and palettes) client-side from bodies it already
120
+ // holds, so a set trimmed to the entry view would leave a later one's title
121
+ // block falling back to a face with different metrics.
122
+ const fonts = [...new Set(palette.map((n) => fontFaceCSS(themes[n], allFaces())))].join("");
123
+ const sprite = [...defs.keys()].sort().map((k) => defs.get(k)).join("");
124
+ const title = opts.title ?? list.find((v) => v.name === entry)?.title ?? files[0]?.name.replace(/\.squinch$/, "") ?? "diagram";
125
+ const html = document({
126
+ title,
127
+ fonts,
128
+ sprite,
129
+ palette: palette.map((n) => themes[n]),
130
+ entry,
131
+ entryBody: bodies.get(`${entry}|${palette[0]}`),
132
+ bodies,
133
+ views: list,
134
+ flows,
135
+ });
136
+ return {
137
+ html,
138
+ diagnostics,
139
+ ok: true,
140
+ manifest: {
141
+ views: list.map((v) => v.name),
142
+ themes: palette,
143
+ renders: bodies.size,
144
+ bytes: html.length,
145
+ flows,
146
+ },
147
+ };
148
+ }
149
+ function document(a) {
150
+ // chrome colours come from the same theme tokens the diagram draws with, so
151
+ // the frame around a dark diagram is dark (DESIGN §10)
152
+ const vars = (t, sel) => `${sel}{--sq-canvas:${t.canvas};--sq-ink:${t.ink};--sq-muted:${t.muted};--sq-border:${t.border};--sq-surface:${t.surface}}`;
153
+ const themeVars = a.palette
154
+ .map((t, i) => vars(t, i === 0 ? ":root,:root[data-theme=\"" + t.name + "\"]" : `:root[data-theme="${t.name}"]`))
155
+ .join("");
156
+ const L = [];
157
+ L.push("<!doctype html>");
158
+ L.push(`<html lang="en" data-theme="${attr(a.palette[0].name)}">`);
159
+ L.push("<head>");
160
+ L.push('<meta charset="utf-8">');
161
+ L.push('<meta name="viewport" content="width=device-width,initial-scale=1">');
162
+ L.push(`<title>${text(a.title)}</title>`);
163
+ L.push(`<style>${a.fonts}${themeVars}${CHROME_CSS}</style>`);
164
+ L.push("</head>");
165
+ L.push("<body>");
166
+ // never display:none — some engines stop resolving <use> into a hidden tree
167
+ L.push(`<svg id="sq-defs" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">` +
168
+ `<defs>${a.sprite}</defs></svg>`);
169
+ // No breadcrumb: the view tabs in the footer name where you are and where
170
+ // you can go, so the header keeps only the document-level controls.
171
+ L.push('<header id="sq-bar"><span id="sq-step"></span>' +
172
+ (a.palette.length > 1 ? '<button id="sq-theme" type="button" title="Change palette (t)">◐</button>' : "") +
173
+ (a.views.length > 1 ? '<button id="sq-present" type="button" title="Present (p)">Present</button>' : "") +
174
+ "</header>");
175
+ L.push('<main id="sq-stage"><div id="sq-ghost" aria-hidden="true"></div><div id="sq-live">');
176
+ L.push(a.entryBody);
177
+ L.push("</div></main>");
178
+ if (a.views.length > 1)
179
+ L.push('<footer id="sq-foot"><nav id="sq-tabs" aria-label="Views"></nav></footer>');
180
+ for (const [key, svg] of a.bodies) {
181
+ if (key === `${a.entry}|${a.palette[0].name}`)
182
+ continue; // already inline
183
+ L.push(`<template data-key="${attr(key)}">${svg}</template>`);
184
+ }
185
+ L.push(`<script type="application/json" id="sq-data">` +
186
+ jsonIsland({
187
+ views: a.views, entry: a.entry, themes: a.palette.map((t) => t.name), flows: a.flows,
188
+ }) +
189
+ `</script>`);
190
+ L.push(`<script>${RUNTIME_JS}</script>`);
191
+ L.push("</body>");
192
+ L.push("</html>");
193
+ return L.join("\n") + "\n";
194
+ }
195
+ const CHROME_CSS = "*{box-sizing:border-box}" +
196
+ "body{margin:0;background:var(--sq-canvas);color:var(--sq-ink);" +
197
+ "font:13px/1.5 system-ui,-apple-system,sans-serif;height:100vh;display:flex;flex-direction:column}" +
198
+ "#sq-bar{display:flex;align-items:center;justify-content:flex-end;gap:12px;padding:10px 16px;flex:none}" +
199
+ "#sq-theme{font:inherit;background:var(--sq-surface);color:var(--sq-muted);cursor:pointer;" +
200
+ "border:1px solid var(--sq-border);border-radius:6px;padding:2px 8px;flex:none}" +
201
+ "#sq-stage{position:relative;flex:1;min-height:0;overflow:auto;display:grid;place-items:center}" +
202
+ // out of flow ALWAYS, not just while a dive's inline styles are on it. As a
203
+ // grid child the empty ghost owned a row, and default align-content:normal
204
+ // stretches auto rows — so the moment cleanup returned it to flow, the live
205
+ // layer's row moved down and the whole diagram jumped a beat after the
206
+ // zoom landed. The dive sets left/top/size inline per flight; this base
207
+ // rule is what holds between flights.
208
+ "#sq-ghost{position:absolute;left:0;top:0;pointer-events:none}" +
209
+ "#sq-live svg,#sq-ghost svg{max-width:100%;height:auto;display:block}" +
210
+ // a card that leads somewhere says so — and only one that does. The class is
211
+ // applied by the runtime, which is the only thing that knows whether a path
212
+ // resolves to a view; styling every card invited a click that did nothing.
213
+ "#sq-live .sq-zoom{cursor:zoom-in}" +
214
+ "#sq-step{color:var(--sq-muted);font-variant-numeric:tabular-nums;flex:none}" +
215
+ "#sq-present{font:inherit;background:var(--sq-surface);color:var(--sq-muted);cursor:pointer;" +
216
+ "border:1px solid var(--sq-border);border-radius:6px;padding:2px 10px;flex:none}" +
217
+ "#sq-foot{display:flex;justify-content:center;padding:10px;flex:none;min-width:0}" +
218
+ // the SPA's view picker, verbatim in spirit: a quiet bar of view names, the
219
+ // active one on a plate. Scrolls sideways rather than wrapping when a deck
220
+ // outgrows it — a two-row bar reads as two bars.
221
+ "#sq-tabs{display:flex;gap:2px;padding:3px;max-width:calc(100% - 16px);overflow-x:auto;" +
222
+ "border:1px solid var(--sq-border);border-radius:8px;background:var(--sq-surface)}" +
223
+ "#sq-tabs button{font:inherit;font-size:12px;white-space:nowrap;padding:3px 10px;border-radius:5px;" +
224
+ "border:0;background:none;color:var(--sq-muted);cursor:pointer}" +
225
+ "#sq-tabs button:hover{color:var(--sq-ink)}" +
226
+ "#sq-tabs button.on{background:var(--sq-canvas);color:var(--sq-ink);" +
227
+ "box-shadow:inset 0 0 0 1px var(--sq-border)}" +
228
+ // presenting: full bleed, chrome floats over the canvas and fades when idle
229
+ "body.presenting #sq-bar,body.presenting #sq-foot{position:fixed;left:0;right:0;z-index:2;" +
230
+ "transition:opacity .3s;background:transparent}" +
231
+ "body.presenting #sq-bar{top:0}body.presenting #sq-foot{bottom:0}" +
232
+ "body.presenting #sq-stage{padding:0}" +
233
+ "body.presenting.idle #sq-bar,body.presenting.idle #sq-foot{opacity:0;pointer-events:none}" +
234
+ "body.presenting.idle{cursor:none}" +
235
+ "@media (prefers-reduced-motion:reduce){#sq-live,#sq-ghost{transition:none!important}}";