Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/crystal.js

65.0 KiB, 1 run

created by r2519314175:1353, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* crystal.js — a Diamond's crystal, and the page that draws it.
2 *
3 * A crystal is two files now. `crystal.json` is the memory: capped, folded, and
4 * put in the standing context. `crystal.html` is a self-contained page that
5 * renders it, and every Diamond starts on the one below, so the shipped page is
6 * the view most people actually see rather than a placeholder behind a feature
7 * flag.
8 *
9 * THE PAGE IS NOT TRUSTED, AND THAT IS THE WHOLE MECHANISM. It is written by a
10 * model that may itself have been steered by a web page it read a moment ago, it
11 * is exempt from the cap on the data, it is absent from the standing context,
12 * unseen by the reducer and unseen by the fold diff — the one injection in this
13 * app that survives a turn, and it syncs to every device. So it runs in an
14 * `iframe` with `sandbox="allow-scripts"` and nothing else: an opaque origin,
15 * with no reach into `localStorage` where the key lives, no OPFS, no wasm
16 * bridge, and no read of the app's DOM.
17 *
18 * `allow-same-origin` is the attribute that would undo all of it. A blob: URL
19 * INHERITS OUR ORIGIN, so a page rendered that way unsandboxed runs AS US.
20 * `www/js/web.js` at ~line 673 carries the long form of that argument for the
21 * agent's own preview frame; this is the same trap and the same answer. Do not
22 * add `allow-forms`, `allow-popups`, `allow-modals` or `allow-top-navigation`
23 * either.
24 *
25 * AND THE SANDBOX IS ONLY HALF OF IT. Isolation stops the page READING anything
26 * of ours; it does not stop it SENDING. So every page — the shipped one and any
27 * a model writes later — is served under a `Content-Security-Policy` that
28 * forbids the network outright, injected on the way into the frame. That is not
29 * a restriction laid on top of the design, it is the design written down: a page
30 * that is self-contained, with its CSS and its script inline and its images as
31 * data URIs, has nothing to fetch. See `armour` below.
32 *
33 * THE VERB LIST GREW ONCE, on 2026-08-13, and the paragraph it replaced said it
34 * never would. That paragraph was right about `ask` and wrong to bundle `save`
35 * in with it, so the argument is worth restating rather than deleting.
36 *
37 * A parent cannot verify user activation across this boundary: a timer in the
38 * page is indistinguishable from a click. For `ask` that is decisive, because
39 * asking spends the user's money, and a page that could ask in a loop could
40 * spend it in a loop -- which is why the ask-the-daimon box lives in app chrome
41 * BELOW the frame, where a click is provably a person. For `save` the same
42 * argument gives a much smaller answer: a runaway page can write only inside its
43 * own Diamond, only text it composed itself, and only what the budget below
44 * allows. So the verb exists and the loop is bounded instead of forbidden.
45 *
46 * The cost of growing the list is real and is paid by OLD pages, not new ones: a
47 * page written before today does not speak `save`, and nothing can teach it --
48 * a migration can rename a file but cannot rewrite a model-authored page. So a
49 * verb may be ADDED and may never change meaning, and a page that does not use
50 * one is unaffected. `ready`, `asset`, `save`, `rendered`, `height`, `open`.
51 *
52 * FAILING IS VISIBLE. A page that never says `ready`, or that reports rendering
53 * less than the data holds, is replaced by the built-in view with a note saying
54 * which of the two happened and a button that puts the standard page back.
55 * Silent degradation is how a broken page stays broken for a month, and a page
56 * that quietly showed three sections of seven after a key rename would be
57 * invisible to the parent and invisible to the model too.
58 *
59 * LEADING-UNDERSCORE KEYS ARE THE CHANNEL'S OWN. The page is in an opaque origin
60 * and can see neither the app's stylesheet nor its translation table, so the
61 * parent hands both to it inside the `data` reply, under `_theme` and `_labels`.
62 * They are never the model's, they are stripped from anything that goes back out,
63 * and the built-in view ignores them. A key beginning with `_` is thus reserved
64 * on the wire, and everything else — recognised or not — is content.
65 *
66 * window.DaimondCrystal = { CORE_KEYS, DEFAULT_PAGE, FALLBACK_MS, PROTOCOL,
67 * parse, toMarkdown, fromMarkdown,
68 * mount, unmount, fallback }
69 */
70(function () {
71 'use strict';
72
73 /// The core schema, in the order everything renders it. Extra top-level keys
74 /// are permitted and nothing may ever drop one it does not recognise.
75 var CORE_KEYS = ['title', 'summary', 'sections', 'facts', 'open', 'links'];
76
77 /// The channel's version. Every message carries `{dc:1, v:1}`; a message
78 /// without both is not ours and is not read.
79 var PROTOCOL = 1;
80
81 /// How long a page has to say `ready`, and then how long it has to say
82 /// `rendered`. Short enough that a broken page does not look like a slow one.
83 var FALLBACK_MS = 1500;
84
85 /// What the frame may ask to be. A page that reports nothing keeps the height
86 /// the stylesheet gave it and scrolls inside itself, which is ugly but loses
87 /// nothing; a page reporting a silly number is clamped rather than believed.
88 var MIN_H = 40;
89 var MAX_H = 20000;
90
91 /// The longest href the parent will carry out of the frame.
92 var HREF_MAX = 2048;
93
94
95 // ── Small shared helpers ────────────────────────────────────────
96
97 /// A string from anything, without `null` becoming the word.
98 function str(v) { return v == null ? '' : String(v); }
99
100 /// An array from anything, so a malformed crystal renders rather than throws.
101 function arr(v) { return Array.isArray(v) ? v : []; }
102
103 /// A plain object from anything. An array is not one: `sections` is a list and
104 /// the document is not.
105 function obj(v) {
106 return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
107 }
108
109 function own(o, k) { return Object.prototype.hasOwnProperty.call(o, k); }
110
111 /// Whether a value carries content. One predicate, used by the coverage check,
112 /// by `toMarkdown` and by both views, so "the page did not render this" and
113 /// "there was nothing to render" can never disagree.
114 function hasContent(v) {
115 if (v == null) return false;
116 if (typeof v === 'string') return v.trim() !== '';
117 if (typeof v === 'number' || typeof v === 'boolean') return true;
118 if (Array.isArray(v)) return v.length > 0;
119 if (typeof v === 'object') {
120 for (var k in v) if (own(v, k)) return true;
121 return false;
122 }
123 return false;
124 }
125
126 /// The top-level keys of a crystal that carry something, channel keys aside.
127 function contentKeys(data) {
128 var d = obj(data), out = [];
129 for (var k in d) {
130 if (!own(d, k) || k.charAt(0) === '_') continue;
131 if (hasContent(d[k])) out.push(k);
132 }
133 return out;
134 }
135
136 /// The three keys `toMarkdown` has a markdown form for, and therefore the three
137 /// the migration can produce. Everything else travels as data.
138 var MD_KEYS = ['title', 'summary', 'sections'];
139
140 /// The keys markdown cannot carry, in a stable order: the rest of the core
141 /// first, then everything the reducer invented, in the order it wrote them.
142 function extraKeys(data) {
143 var d = obj(data), out = [], i;
144 for (i = 0; i < CORE_KEYS.length; i++) {
145 var c = CORE_KEYS[i];
146 if (MD_KEYS.indexOf(c) >= 0) continue;
147 if (own(d, c) && hasContent(d[c])) out.push(c);
148 }
149 for (var k in d) {
150 if (!own(d, k) || k.charAt(0) === '_') continue;
151 if (CORE_KEYS.indexOf(k) >= 0) continue;
152 if (hasContent(d[k])) out.push(k);
153 }
154 return out;
155 }
156
157 /// A string from the app's table, or the English written here while the key is
158 /// still on its way into the other seven locales. The same shape as the app's
159 /// own `tOr`, because this file must hold no strings of its own that a reader
160 /// could ever see untranslated.
161 function tr(opts, key, english, vars) {
162 var f = (opts && typeof opts.t === 'function') ? opts.t : null;
163 if (f) {
164 var s = f(key, vars);
165 if (s != null && s !== key) return String(s);
166 }
167 return String(english).replace(/\{(\w+)\}/g, function (whole, k) {
168 return (vars && vars[k] != null) ? String(vars[k]) : whole;
169 });
170 }
171
172
173 // ── parse ───────────────────────────────────────────────────────
174
175 /// Read `crystal.json`. Never throws: a Diamond whose crystal will not parse
176 /// must still draw something, because the alternative is a blank face and an
177 /// agent handed an empty crystal writing a new one over work it never saw.
178 ///
179 /// `error` is diagnostic — the engine's own words, for a console line or a
180 /// detail row. The sentence shown to the reader is the app's
181 /// `crystal.json_invalid`, never this.
182 function parse(text) {
183 var s = str(text);
184 if (!s.trim()) return { ok: true, data: {}, error: '' };
185 var d;
186 try {
187 d = JSON.parse(s);
188 } catch (e) {
189 return { ok: false, data: null, error: String((e && e.message) || e) };
190 }
191 if (d === null || typeof d !== 'object' || Array.isArray(d)) {
192 return { ok: false, data: null, error: 'The crystal must be a JSON object.' };
193 }
194 return { ok: true, data: d, error: '' };
195 }
196
197
198 // ── The migration, and the property that proves it ──────────────
199 //
200 // `crystal.md` becomes `crystal.json`, and the assertion is not the steps but
201 // the round trip: `toMarkdown(fromMarkdown(md)) === md`, byte for byte, over
202 // the real crystals in a seeded workspace. The same conversion exists in Rust,
203 // which does the actual migration, and `verify_crystalmigrate` compares the two
204 // — so THE TWO MUST BE ONE FUNCTION IN TWO LANGUAGES, not two functions that
205 // each happen to round-trip against themselves. This pair follows Rust.
206 //
207 // NOTHING IS JOINED AND NOTHING IS TRIMMED. `toMarkdown` writes `# `, the
208 // title, one newline; then the summary exactly as it stands; then, per section,
209 // `## `, the heading, one newline, and the body exactly as it stands. No
210 // separator is inserted anywhere and no trailing newline is invented. The
211 // blank line a reader sees between two sections is therefore the first
212 // character of the following body, carried there by the split and put back by
213 // the concatenation.
214 //
215 // That is what makes losslessness STRUCTURAL rather than enumerated. The
216 // alternative — join the pieces with a blank line and strip the blank lines
217 // off each piece on the way in — reads more tidily in the JSON and normalises
218 // on the way out, and normalisation is precisely what cannot round-trip: a
219 // document with three blank lines between two sections, or with none, comes
220 // back with one either way and no longer matches the file it came from.
221 //
222 // AN EMPTY HEADING EMITS NO MARKER. It is the no-headings case, which owns the
223 // whole document and has no `## ` of its own to put back. The splitter refuses
224 // to read a bare `## ` line as a heading for the same reason, so the two can
225 // never collide; the `# ` line is held to the same rule.
226 //
227 // And the check is MECHANICAL, not a list of shapes it knows about:
228 // `fromMarkdown` parses, renders straight back, compares byte for byte, and
229 // falls back to a single verbatim section when they differ. A ladder of cases
230 // covers the inputs somebody thought of. This covers the one nobody did, which
231 // is the one that turns up in a real workspace.
232 //
233 // Two cases are still worth naming. A `##` INSIDE A FENCED CODE BLOCK is not a
234 // heading: the scan toggles on fences and a `## ` under an open one is body
235 // text. The round trip would survive reading it as a heading — the pieces
236 // rejoin to the same bytes either way — so nothing about losslessness catches
237 // that mistake; what it produces is a section whose body opens with a dangling
238 // fence, which every renderer downstream then gets wrong. TEXT BEFORE THE FIRST
239 // HEADING has no home in the schema, so a `# ` line that is not the first line
240 // of the file is not promoted to `title` at all — the whole run before the
241 // first `## ` becomes the summary, hash and all, which reproduces exactly
242 // because the summary is carried verbatim.
243 //
244 // Every rule below is Rust's, deliberately and to the letter, down to the ones
245 // that look arbitrary from here: a fence is any line whose trimmed form opens
246 // with three backticks or three tildes and it merely TOGGLES, a heading needs
247 // text that survives a trim, and a heading line need not end in a newline —
248 // one that does not simply fails the comparison and sends the document
249 // verbatim. Two functions that each round-trip against themselves are still two
250 // functions, and `verify_crystalmigrate` compares them against each other.
251
252 /// Whether a line opens or closes a fenced code block.
253 function isFence(line) {
254 var t = line.trim();
255 return t.indexOf('```') === 0 || t.indexOf('~~~') === 0;
256 }
257
258 /// Find the structural headings of a markdown file: the `# ` line if it is the
259 /// first line, and every `## ` line outside a fenced code block. A heading whose
260 /// text does not survive a trim is not a heading — a section with an empty
261 /// heading is how the no-headings case is spelled, and the renderer drops the
262 /// marker for it, so a bare `## ` left in the body is the only way it survives.
263 function scan(md) {
264 var h1 = null, h2 = [];
265 var i = 0, n = md.length;
266 var fenced = false;
267 while (i < n) {
268 var j = md.indexOf('\n', i);
269 var end = (j < 0) ? n : j + 1; // past the newline
270 var line = md.slice(i, (j < 0) ? n : j);
271 if (isFence(line)) {
272 fenced = !fenced;
273 } else if (!fenced) {
274 // The title is the FIRST line or nothing. A `# ` further down is
275 // somebody's sub-heading, and hoisting it would move text the user
276 // put after it to before it.
277 if (i === 0 && line.indexOf('# ') === 0 && line.slice(2).trim() !== '') {
278 h1 = { after: end, title: line.slice(2) };
279 }
280 if (line.indexOf('## ') === 0 && line.slice(3).trim() !== '') {
281 h2.push({ start: i, after: end, heading: line.slice(3) });
282 }
283 }
284 i = end;
285 }
286 return { h1: h1, h2: h2 };
287 }
288
289 /// The structural reading of a markdown file. Every run of text is taken byte
290 /// for byte; the only characters this drops are the newlines that end the
291 /// heading lines, and `toMarkdown` puts those back.
292 function decompose(md) {
293 var sc = scan(md);
294 var title = sc.h1 ? sc.h1.title : '';
295 var pos = sc.h1 ? sc.h1.after : 0;
296 var firstH2 = sc.h2.length ? sc.h2[0].start : md.length;
297 var summary = md.slice(pos, firstH2);
298 var secs = [];
299 for (var i = 0; i < sc.h2.length; i++) {
300 var end = (i + 1 < sc.h2.length) ? sc.h2[i + 1].start : md.length;
301 secs.push({ heading: sc.h2[i].heading, body: md.slice(sc.h2[i].after, end) });
302 }
303 // No headings at all becomes one section with an empty heading, per the
304 // schema. Not a bare summary: a summary is what a title is followed BY, and
305 // there is no title here.
306 if (!title && !secs.length) return { sections: [{ heading: '', body: summary }] };
307 var data = {};
308 if (title) data.title = title;
309 if (summary) data.summary = summary;
310 if (secs.length) data.sections = secs;
311 return data;
312 }
313
314 /// A markdown crystal read as data. The answer is always one this file's own
315 /// `toMarkdown` reproduces exactly, because it is checked rather than trusted.
316 function fromMarkdown(md) {
317 var s = str(md);
318 // Nothing stays nothing. A new Diamond's crystal is an empty file, and it
319 // should arrive as an empty object rather than a section holding no text.
320 if (!s) return {};
321 var d = decompose(s);
322 if (toMarkdown(d) === s) return d;
323 return { sections: [{ heading: '', body: s }] };
324 }
325
326 /// Data rendered back to markdown: pure concatenation, nothing joined, nothing
327 /// trimmed, no trailing newline synthesised. An empty title or heading emits no
328 /// marker at all.
329 ///
330 /// The three keys the migration produces come out as markdown. Everything else
331 /// — the rest of the core schema and whatever the reducer invented — comes out
332 /// as one fenced JSON block, because markdown has no faithful form for a list
333 /// of key/value pairs and reshaping it is exactly the loss this design exists
334 /// to prevent. That block also needs no labels, so this function stays
335 /// locale-free: it is the migration's serialiser and the verifier's oracle,
336 /// not a display path. It is the one place a separator is inserted, and it
337 /// cannot touch the round trip: a migrated crystal never carries those keys, so
338 /// the branch never runs while the property is being checked.
339 function toMarkdown(data) {
340 var d = obj(data), out = '', i;
341 var title = str(d.title);
342 if (title) out += '# ' + title + '\n';
343 out += str(d.summary);
344 var secs = arr(d.sections);
345 for (i = 0; i < secs.length; i++) {
346 var s = obj(secs[i]);
347 var h = str(s.heading);
348 if (h) out += '## ' + h + '\n';
349 out += str(s.body);
350 }
351 var rest = extraKeys(d);
352 if (rest.length) {
353 var bag = {};
354 for (i = 0; i < rest.length; i++) bag[rest[i]] = d[rest[i]];
355 if (out) out = out.replace(/\n*$/, '\n\n');
356 out += '```json\n' + JSON.stringify(bag, null, 2) + '\n```';
357 }
358 return out;
359 }
360
361
362 // ── The theme and the words, handed across the boundary ─────────
363 //
364 // The page cannot see `variables.css` and cannot see the translation table, so
365 // it is told. Both ride inside the `data` reply rather than in verbs of their
366 // own, which is what keeps the verb list at five.
367 //
368 // The colours are RESOLVED, not named: a probe element is asked what
369 // `var(--text-primary)` actually comes out as in the app's current cascade, so
370 // all eleven palettes and both skins work without this file knowing one of
371 // them by name, and a custom property defined in terms of another resolves
372 // rather than arriving as the literal text `var(--bg-primary)`.
373
374 var TONES = [
375 ['bg', '--bg-secondary'],
376 ['surface', '--bg-tertiary'],
377 ['text', '--text-primary'],
378 ['muted', '--text-muted'],
379 ['border', '--border'],
380 ['accent', '--accent'],
381 ['accentText', '--accent-text'],
382 ];
383
384 /// What the app looks like right now, in terms a page in an opaque origin can
385 /// use directly.
386 function themeOf(el) {
387 var root = document.documentElement;
388 var out = {
389 ink: root.getAttribute('data-ink') || 'light',
390 theme: root.getAttribute('data-theme') || '',
391 skin: root.getAttribute('data-skin') || 'sharp',
392 font: '', mono: '', size: '', radius: '',
393 };
394 var probe = document.createElement('div');
395 probe.setAttribute('aria-hidden', 'true');
396 probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;'
397 + 'visibility:hidden;pointer-events:none';
398 (el || document.body).appendChild(probe);
399 try {
400 for (var i = 0; i < TONES.length; i++) {
401 probe.style.color = 'var(' + TONES[i][1] + ')';
402 out[TONES[i][0]] = getComputedStyle(probe).color || '';
403 }
404 probe.style.color = '';
405 var cs = getComputedStyle(probe);
406 out.font = cs.fontFamily || '';
407 out.size = cs.fontSize || '';
408 probe.style.fontFamily = 'var(--font-mono)';
409 out.mono = getComputedStyle(probe).fontFamily || '';
410 out.radius = getComputedStyle(document.documentElement)
411 .getPropertyValue('--radius').trim() || '';
412 } catch (e) {
413 // A theme we could not read is not a reason to show nothing; the page
414 // carries its own neutral defaults for exactly this.
415 }
416 if (probe.parentNode) probe.parentNode.removeChild(probe);
417 return out;
418 }
419
420 /// The field names, translated once by the parent because the page cannot.
421 function labelsFor(opts) {
422 return {
423 facts: tr(opts, 'crystal.field_facts', 'Facts'),
424 open: tr(opts, 'crystal.field_open', 'Open threads'),
425 links: tr(opts, 'crystal.field_links', 'Links'),
426 other: tr(opts, 'crystal.other_fields', 'Other fields'),
427 other_note: tr(opts, 'crystal.other_fields_note',
428 'Kept as they are, and shown here so nothing vanishes.'),
429 empty: tr(opts, 'crystal.empty', 'The crystal is empty. Steer it below to begin.'),
430 };
431 }
432
433 /// The data as the page receives it: the model's keys untouched, the channel's
434 /// two added, and any `_` key the model happened to write stripped — the
435 /// underscore is reserved on the wire, so a crystal carrying `_theme` cannot
436 /// dress itself up as the parent.
437 function wireData(data, opts, el) {
438 var d = obj(data), out = {};
439 for (var k in d) {
440 if (!own(d, k) || k.charAt(0) === '_') continue;
441 out[k] = d[k];
442 }
443 out._theme = themeOf(el);
444 out._labels = labelsFor(opts);
445 return out;
446 }
447
448
449 // ── The policy every page runs under ────────────────────────────
450 //
451 // THE SANDBOX STOPS THE PAGE READING OUR STORAGE. IT DOES NOT STOP IT SENDING.
452 // An opaque origin still has `fetch`, still has an `img` it can point at a
453 // server, and the page is handed the whole crystal by design. So the isolation
454 // that makes the frame safe to run says nothing at all about the frame walking
455 // the memory out.
456 //
457 // That matters more here than anywhere else in the app, because of who wrote
458 // the page: a model that may itself have been steered by a web page it read a
459 // moment ago. A line it was talked into leaving behind is absent from the
460 // standing context, unseen by the reducer and unseen by the fold diff — and it
461 // syncs to every device. (This list used to open with "exempt from the cap",
462 // which stopped being true on 2026-08-09 when the page got a ceiling of its
463 // own. Dropping it costs the argument nothing: what makes a line durable is
464 // that nothing READS the page, not that nothing weighs it.) `ask()` was
465 // dropped from the verb
466 // list over exactly that shape, so leaving the same hole open in the transport
467 // would be inconsistent. The daimon can exfiltrate too, but only through the
468 // egress gate, where a person sees it and says yes once; a page would do it
469 // silently, on every render, for ever, with no gate involved. That difference
470 // is the entire reason there is a gate.
471 //
472 // THE POLICY IS NOT A RESTRICTION ADDED ON TOP. It is the page the design
473 // already asks for, written down: self-contained, CSS and JS inlined, images as
474 // data URIs, nothing that refers outside itself. A page that breaks under it is
475 // a page that was already breaking the rule it was built to.
476 //
477 // It is injected into every page, including one that carries a policy of its
478 // own, because the browser enforces every policy on a document at once and the
479 // effective one is their intersection — so ours can only tighten, never loosen,
480 // whatever the author wrote.
481 //
482 // WHERE IT GOES IS THE PART TO GET RIGHT. Never before the doctype: a `<meta>`
483 // ahead of `<!doctype html>` puts the document in quirks mode, which would
484 // change how every authored page lays out and would be a rendering bug we
485 // caused. First child of `<head>` where there is one; failing that after the
486 // `<html>` tag, where the parser opens a head and puts it there; failing that
487 // after a leading doctype; and only with neither, at the very start.
488 //
489 // Going in first also pushes a page's own `<meta charset>` a hundred-odd bytes
490 // further down, and an encoding declaration only counts inside the first 1024.
491 // That is why `PAGE_TYPE` below states the encoding on the resource itself,
492 // where it outranks any meta: the blob is built from a JavaScript string, so it
493 // IS UTF-8 whatever the page believes, and saying so removes the question
494 // rather than leaving it to a byte count.
495
496 // `data:` for pictures and typefaces, and no host anywhere. The policy is not a
497 // restriction laid on top of the design -- it IS the design: a self-contained page
498 // with its CSS, its script and its assets inlined, referring to nothing outside
499 // itself. A data URI cannot make a network request, so admitting one costs nothing
500 // the rest of the policy is buying; leaving `font-src` out would have banned an
501 // inlined typeface while allowing an inlined picture, which is an accident rather
502 // than a rule.
503 var PAGE_CSP = 'default-src \'none\'; script-src \'unsafe-inline\'; '
504 + 'style-src \'unsafe-inline\'; img-src data:; font-src data:';
505
506 var PAGE_TYPE = 'text/html;charset=utf-8';
507
508 var CSP_META = '<meta http-equiv="Content-Security-Policy" content="' + PAGE_CSP + '">';
509
510 /// Whether a page already declares a policy of its own. Only reported, never
511 /// acted on: ours goes in either way and the two intersect.
512 var CSP_HAS = /<meta[^>]+http-equiv\s*=\s*["']?\s*content-security-policy/i;
513
514 /// A page with the policy in it, and where it had to go.
515 function armour(html) {
516 var s = String(html);
517 var carried = CSP_HAS.test(s);
518 var m = /<head\b[^>]*>/i.exec(s);
519 if (m) return insertCsp(s, m.index + m[0].length, 'head', carried);
520 m = /<html\b[^>]*>/i.exec(s);
521 if (m) return insertCsp(s, m.index + m[0].length, 'html', carried);
522 m = /^\s*<!doctype\b[^>]*>/i.exec(s);
523 if (m) return insertCsp(s, m[0].length, 'doctype', carried);
524 return insertCsp(s, 0, 'start', carried);
525 }
526
527 function insertCsp(s, at, where, carried) {
528 return {
529 html: s.slice(0, at) + CSP_META + s.slice(at),
530 injected: true,
531 carried: carried,
532 at: where,
533 };
534 }
535
536
537 // ── mount ───────────────────────────────────────────────────────
538 //
539 // One frame at a time, because there is exactly one caller and a second live
540 // channel would mean two `message` listeners racing over one reply. `mount`
541 // owns the whole lifecycle — build, wire, time, and swap in the built-in view
542 // itself when the page fails. The app does not drive any of that, and there
543 // are NO custom events anywhere in this file: the app re-mounts after a write,
544 // which is the one rule written straight out of the last session's integration
545 // bug, where two lanes each invented a name for the same signal.
546
547 var live = null;
548
549 /// Render a Diamond's crystal into `el` using its own page.
550 ///
551 /// `opts` is `{ id, data, page, onOpen, onKeys, onFallback, onAsset, onReset, t }`.
552 function mount(el, opts) {
553 unmount();
554 if (!el) return;
555 opts = opts || {};
556 clearOurs(el);
557
558 var page = str(opts.page).trim() ? String(opts.page) : DEFAULT_PAGE;
559 var data = obj(opts.data);
560
561 var wrap = document.createElement('div');
562 wrap.id = 'crystal-frame-wrap';
563
564 var frame = document.createElement('iframe');
565 frame.className = 'crystal-frame';
566 // `allow-scripts` and NOTHING else, ever. See the head of this file.
567 frame.setAttribute('sandbox', 'allow-scripts');
568 frame.setAttribute('referrerpolicy', 'no-referrer');
569 frame.setAttribute('title', tr(opts, 'crystal.view_crystal', 'Crystal'));
570
571 // The page cannot reach the network, whoever wrote it. See above.
572 var armed = armour(page);
573 var url = URL.createObjectURL(new Blob([armed.html], { type: PAGE_TYPE }));
574 frame.src = url;
575 wrap.appendChild(frame);
576 el.appendChild(wrap);
577
578 live = {
579 el: el, opts: opts, data: data, frame: frame, url: url,
580 // The record, not the page: `_state` reports this and nothing holds the
581 // armoured text once the blob has it.
582 csp: { policy: PAGE_CSP, injected: armed.injected, carried: armed.carried, at: armed.at },
583 ready: false, reported: false, done: false, loads: 0, keys: [],
584 timer: 0, rtimer: 0, watch: null, height: 0, onMsg: null, onLoad: null,
585 };
586
587 live.onMsg = function (e) { onMessage(e); };
588 window.addEventListener('message', live.onMsg);
589
590 // A second load is the page navigating ITSELF somewhere. The sandbox stops
591 // it taking the tab, but a `postMessage` to an opaque origin must be sent
592 // with `'*'` — there is no origin to name — so a frame that has moved on
593 // would receive the next reply. Nothing is sent after this, and the page is
594 // treated as broken, because a crystal page has no business navigating.
595 live.onLoad = function () {
596 if (!live) return;
597 live.loads++;
598 if (live.loads === 1) {
599 // The document is fetched; the URL has done its work.
600 try { URL.revokeObjectURL(live.url); } catch (e) { /* already gone */ }
601 live.url = '';
602 return;
603 }
604 fell('partial');
605 };
606 frame.addEventListener('load', live.onLoad);
607
608 live.timer = setTimeout(function () { fell('timeout'); }, FALLBACK_MS);
609
610 // The palette can change under us at any moment, and `data-theme` is what
611 // the app stamps — watching the attribute is watching the actual event
612 // rather than inventing a signal for it. The page is simply sent its data
613 // again, which is the only thing it knows how to be told anything by.
614 if (window.MutationObserver) {
615 live.watch = new MutationObserver(function () { sendData(); });
616 live.watch.observe(document.documentElement, {
617 attributes: true,
618 attributeFilter: ['data-theme', 'data-ink', 'data-skin'],
619 });
620 }
621 }
622
623 /// Take down whatever is mounted. Safe to call when nothing is.
624 function unmount() {
625 if (!live) return;
626 var l = live;
627 live = null;
628 detach(l);
629 if (l.el) clearOurs(l.el);
630 }
631
632 /// Everything the channel holds open, released. The DOM is left alone: the
633 /// fallback view is put up by `fell` after this runs.
634 function detach(l) {
635 if (l.onMsg) window.removeEventListener('message', l.onMsg);
636 if (l.onLoad && l.frame) l.frame.removeEventListener('load', l.onLoad);
637 if (l.watch) { try { l.watch.disconnect(); } catch (e) { /* gone */ } }
638 clearTimeout(l.timer);
639 clearTimeout(l.rtimer);
640 if (l.url) { try { URL.revokeObjectURL(l.url); } catch (e) { /* gone */ } }
641 l.url = '';
642 l.onMsg = null;
643 l.onLoad = null;
644 l.watch = null;
645 }
646
647 /// Remove only what this file put in the container. The crystal bar and the
648 /// ask row above and below are the app's, and a `mount` that emptied its
649 /// parent would take them with it.
650 function clearOurs(el) {
651 var kill = el.querySelectorAll('#crystal-frame-wrap, .crystal-fallback');
652 for (var i = 0; i < kill.length; i++) {
653 if (kill[i].parentNode) kill[i].parentNode.removeChild(kill[i]);
654 }
655 }
656
657
658 // ── The channel ─────────────────────────────────────────────────
659
660 /// The frame's only way back to us. A sandboxed frame is isolated, not
661 /// silenced, and neither is anything else on the page: `message` is a window
662 /// event, so an advert in some other frame, an extension, or a page we merely
663 /// displayed can all post at us. `web.js` at ~line 831 hit this exact trap.
664 /// So: the sender must be OUR frame's window, and the shape must be ours.
665 function onMessage(e) {
666 if (!live || live.done || !live.frame) return;
667 if (e.source !== live.frame.contentWindow) return;
668 var m = e.data;
669 if (!m || m.dc !== 1 || m.v !== PROTOCOL) return;
670 switch (m.cmd) {
671 case 'ready': onReady(); break;
672 case 'asset': onAsset(m); break;
673 case 'save': onSave(m); break;
674 case 'rendered': onRendered(m); break;
675 case 'height': onHeight(m); break;
676 case 'open': onOpen(m); break;
677 default: break; // an unknown verb is a page from a later Daimond; ignore it
678 }
679 }
680
681 /// Post to the frame. The target origin can only be `'*'`: the frame has an
682 /// opaque origin, which names nothing. That is safe because we know what is in
683 /// it — and it stops being true the moment the page navigates, which is why
684 /// `onLoad` above shuts the channel when it does.
685 function toFrame(msg) {
686 if (!live || live.done || !live.frame) return;
687 var w = live.frame.contentWindow;
688 if (!w) return;
689 msg.dc = 1;
690 msg.v = PROTOCOL;
691 try { w.postMessage(msg, '*'); } catch (e) { /* the frame went away */ }
692 }
693
694 function sendData() {
695 if (!live || live.done || !live.ready) return;
696 toFrame({ cmd: 'data', data: wireData(live.data, live.opts, live.el) });
697 }
698
699 /// The page is listening. Its data goes out unprompted, and a second clock
700 /// starts: a page that says `ready` and then never says what it rendered has
701 /// shown us nothing we can check, and unverifiable is the failure this whole
702 /// design is shaped around.
703 function onReady() {
704 if (live.ready) return;
705 live.ready = true;
706 clearTimeout(live.timer);
707 live.timer = 0;
708 sendData();
709 live.rtimer = setTimeout(function () {
710 if (live && !live.reported) fell('partial');
711 }, FALLBACK_MS);
712 }
713
714 /// A text file from this Diamond's scope, read for the page by the app. The
715 /// path is vetted here before anybody is asked for anything: a page that asks
716 /// for `../../other/crystal.json` gets an error, not a file.
717 function onAsset(m) {
718 var id = m.id;
719 var rel = safePath(m.path);
720 if (!rel) { toFrame({ id: id, error: 'path' }); return; }
721 var reader = (live.opts && typeof live.opts.onAsset === 'function')
722 ? live.opts.onAsset : null;
723 if (!reader) { toFrame({ id: id, error: 'unavailable' }); return; }
724 var full = 'diamonds/' + str(live.opts.id) + '/' + rel;
725 var mine = live;
726 Promise.resolve().then(function () {
727 return reader(full, rel);
728 }).then(function (text) {
729 if (live !== mine || live.done) return;
730 toFrame({ id: id, text: str(text) });
731 }, function (err) {
732 if (live !== mine || live.done) return;
733 toFrame({ id: id, error: String((err && err.message) || err) });
734 });
735 }
736
737 /// The files a page may never write, whatever it asks.
738 ///
739 /// **A page must not be able to rewrite itself.** `crystal.html` IS the page and
740 /// `crystal.json` is what it renders; a page that could write either could change its own
741 /// code between one render and the next, and nothing a person reviewed would stay reviewed.
742 /// That is the difference between a page that keeps a log and a page that rewrites the app,
743 /// and it is one line of guard.
744 ///
745 /// `versions/` is the crystal's own history and `.daimond/` holds the rules about what agents
746 /// may do — neither is a page's business either. Everything else under the Diamond is fair
747 /// game, which is the whole point: a capp keeps its data beside itself.
748 ///
749 /// `capp.json` is here as of sharing. It is the DELIVERY RECORD: it says which bytes were
750 /// delivered and at what template version, and it decides which of a capp's files a future
751 /// template fix may replace. A page that could rewrite it could pin itself against every
752 /// update, or claim a file it had edited and have the next version overwrite the user's own
753 /// work. While a capp could only ever have come from this build that was a hazard and not a
754 /// hole — nothing escaped the Diamond and nothing reached anybody else's. **A capp can now
755 /// arrive from another person**, so the page that would be doing the pinning is one the
756 /// receiver did not write, on a machine whose owner never chose it. The share format refuses
757 /// to carry a delivery record at all (fe2o3_sbj `share.rs`), and this is the other half:
758 /// having refused to carry one, it must also refuse to let a page mint one.
759 var PAGE_NEVER_WRITES = /^(crystal\.(json|html|md)$|versions\/|\.daimond\/|capp\.json$)/;
760
761 /// The most a page may write in one call.
762 ///
763 /// A log line is a few hundred bytes and a curated table is tens of kilobytes; a megabyte is
764 /// a page that has misunderstood what it is doing. It is a cap on ONE write, not on the file:
765 /// an append-driven log grows past this a line at a time, which is exactly right.
766 ///
767 /// Measured in UTF-16 code units, because that is what `String.length` counts -- so a log in
768 /// a script outside Latin-1 meets this at roughly half the byte figure. Stated rather than
769 /// corrected: the cap is a guard against a page that has gone wrong, not an accounting
770 /// boundary, and a limit that reads the same in every script is worth more than one that is
771 /// exact in one.
772 var SAVE_MAX = 512 * 1024;
773
774 /// How many times one mounting of a page may save.
775 ///
776 /// The bound that lets `save` exist at all, given that a click and a timer look the same from
777 /// out here (see the head of this file). A person logging meals taps a few dozen times in a
778 /// sitting; a page whose generated code has a loop in it reaches this in a second and is then
779 /// refused, with the frame still up and the app still answering. Per MOUNT, not per session:
780 /// leaving the Diamond and coming back is a person deciding to, and it is the cheapest
781 /// possible way out of a page that has run away with itself.
782 var SAVE_BUDGET = 400;
783
784 /// Answer the page's `save` verb: write one text file into THIS Diamond's directory.
785 ///
786 /// The mirror of [`onAsset`], deliberately: same fence, same reply shape, same Diamond. A
787 /// crystal page has no storage of its own — the frame is `sandbox="allow-scripts"`, so its
788 /// origin is opaque and `localStorage` throws — and no network, because its policy is
789 /// `default-src 'none'`. So without this a page can draw anything and remember nothing, and
790 /// every interactive crystal is a toy that forgets on reload.
791 ///
792 /// `postMessage` works inside that sandbox, which is why this needs no relaxation of it. The
793 /// page asks; the app writes. That is better than granting the frame storage, because the app
794 /// decides what a page may touch and can say no — as it does above.
795 ///
796 /// `mode` is `append` (the default for a log) or `replace`. Append keeps a logger honest
797 /// WITHIN a device: two taps in the same tick both survive, where two replaces lose one.
798 /// It does NOT merge across devices -- sync replaces a Diamond wholesale from the fresher
799 /// copy -- and the first version of this comment claimed it did. See `writeCrystalAsset`.
800 function onSave(m) {
801 var id = m.id;
802 var rel = safePath(m.path);
803 if (!rel) { toFrame({ id: id, error: 'path' }); return; }
804 if (PAGE_NEVER_WRITES.test(rel)) { toFrame({ id: id, error: 'protected' }); return; }
805 var text = str(m.text);
806 if (text.length > SAVE_MAX) { toFrame({ id: id, error: 'too big' }); return; }
807 live.saves = (live.saves || 0) + 1;
808 if (live.saves > SAVE_BUDGET) { toFrame({ id: id, error: 'too many' }); return; }
809 var writer = (live.opts && typeof live.opts.onSave === 'function')
810 ? live.opts.onSave : null;
811 if (!writer) { toFrame({ id: id, error: 'unavailable' }); return; }
812 var full = 'diamonds/' + str(live.opts.id) + '/' + rel;
813 var mine = live;
814 Promise.resolve().then(function () {
815 return writer(full, rel, text, m.mode === 'replace' ? 'replace' : 'append');
816 }).then(function () {
817 if (live !== mine || live.done) return;
818 toFrame({ id: id, ok: true });
819 }, function (err) {
820 if (live !== mine || live.done) return;
821 toFrame({ id: id, error: String((err && err.message) || err) });
822 });
823 }
824
825 /// A relative path inside the Diamond's own folder, or '' for anything that
826 /// leaves it. Backslashes, a scheme, a leading slash and any `..` segment are
827 /// all refused rather than normalised, because a path that needed normalising
828 /// was not one the page should have asked for.
829 function safePath(p) {
830 var s = str(p).trim();
831 if (!s || s.length > 512) return '';
832 if (s.indexOf('\\') >= 0 || s.indexOf('\0') >= 0) return '';
833 if (s.charAt(0) === '/' || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(s)) return '';
834 var parts = s.split('/'), out = [];
835 for (var i = 0; i < parts.length; i++) {
836 var seg = parts[i];
837 if (seg === '' || seg === '.') continue;
838 if (seg === '..') return '';
839 out.push(seg);
840 }
841 return out.length ? out.join('/') : '';
842 }
843
844 /// What the page says it drew. If that does not cover every top-level key with
845 /// content in it, the page is showing less than the Diamond holds and the
846 /// built-in view takes over — the one defect this design is shaped around is a
847 /// key that vanishes from the display because nothing recognised it.
848 function onRendered(m) {
849 live.reported = true;
850 clearTimeout(live.rtimer);
851 live.rtimer = 0;
852 var keys = [], raw = arr(m.keys);
853 for (var i = 0; i < raw.length; i++) {
854 if (typeof raw[i] === 'string') keys.push(raw[i]);
855 }
856 live.keys = keys;
857 if (live.opts && typeof live.opts.onKeys === 'function') {
858 try { live.opts.onKeys(keys.slice()); } catch (e) { /* the app's problem */ }
859 }
860 var want = contentKeys(live.data);
861 for (var j = 0; j < want.length; j++) {
862 if (keys.indexOf(want[j]) < 0) { fell('partial'); return; }
863 }
864 }
865
866 /// The page's own height, so the frame is at least as tall as its content
867 /// and the crystal scrolls in one column rather than two.
868 ///
869 /// `minHeight`, not `height`: `height` would pin the frame to exactly this
870 /// many pixels and undo the CSS rule (crystal.css, `.crystal-frame`) that
871 /// fills the rest of a panel a short page does not reach. A `min-height`
872 /// only ever RAISES the floor -- a page taller than the panel still grows
873 /// past it, a page shorter than the panel still gets the whole panel.
874 function onHeight(m) {
875 var px = Number(m.px);
876 if (!isFinite(px)) return;
877 px = Math.max(MIN_H, Math.min(MAX_H, Math.round(px)));
878 if (px === live.height) return;
879 live.height = px;
880 live.frame.style.minHeight = px + 'px';
881 }
882
883 /// A link the page asked to follow. The app routes it and may refuse; this end
884 /// only decides what is worth passing on.
885 ///
886 /// Same-origin is refused HERE rather than handed over, because the app's
887 /// egress gate allows the app's own address outright — a sensible rule for the
888 /// agent's browser, and the wrong one for a page written by a model, which
889 /// could otherwise walk the memory out a path fragment at a time.
890 function onOpen(m) {
891 var href = str(m.href).trim();
892 if (!href || href.length > HREF_MAX) return;
893 if (!/^(https?:|mailto:)/i.test(href)) return;
894 if (/^https?:/i.test(href)) {
895 var host = '';
896 try { host = new URL(href).host; } catch (e) { return; }
897 if (!host || host === location.host) return;
898 }
899 if (live.opts && typeof live.opts.onOpen === 'function') {
900 try { live.opts.onOpen(href); } catch (e) { /* the app's problem */ }
901 }
902 }
903
904 /// Give up on the page and show the data. Once, and visibly.
905 function fell(reason) {
906 if (!live || live.done) return;
907 live.done = true;
908 var l = live;
909 detach(l);
910 live = {
911 el: l.el, done: true, opts: l.opts, data: l.data,
912 reason: reason, keys: l.keys || [], csp: l.csp || null,
913 };
914 if (l.el) {
915 clearOurs(l.el);
916 var o = {}, k;
917 for (k in l.opts) if (own(l.opts, k)) o[k] = l.opts[k];
918 o.reason = reason;
919 fallback(l.el, l.data, o);
920 }
921 if (l.opts && typeof l.opts.onFallback === 'function') {
922 try { l.opts.onFallback(reason); } catch (e) { /* the app's problem */ }
923 }
924 }
925
926
927 // ── The built-in view ───────────────────────────────────────────
928 //
929 // What the app shows when the page will not. It renders the core schema
930 // properly AND every unknown top-level key generically, because a key that
931 // disappears from the display because nothing recognised it is the defect the
932 // whole design is shaped around — and the reducer is a fresh, tool-less model
933 // under a user-editable prompt rewriting the whole file from one sentence, so
934 // key drift is the expected behaviour, not a risk.
935 //
936 // Markdown goes through `DaimondRender.md`, the app's sanitiser, which drops
937 // `script`, `style`, `iframe`, `form`, `input`, `button` and `svg` whole. It is
938 // right to and must not be loosened for this: unlike the frame, this view
939 // renders inside the app's own page.
940
941 /// The generic view of a crystal, with a note when it is standing in for a
942 /// page that failed. `opts.reason` is `'timeout'`, `'partial'`, or absent.
943 function fallback(el, data, opts) {
944 if (!el) return;
945 opts = opts || {};
946 clearOurs(el);
947 var d = obj(data);
948 var root = document.createElement('div');
949 root.className = 'crystal-fallback';
950
951 if (opts.reason) root.appendChild(fallbackNote(opts));
952
953 var body = document.createElement('div');
954 body.className = 'crystal-fb-body';
955 var drew = 0;
956
957 if (hasContent(d.title)) {
958 drew++;
959 var h1 = document.createElement('h2');
960 h1.className = 'crystal-fb-title';
961 h1.textContent = str(d.title);
962 body.appendChild(h1);
963 }
964 if (hasContent(d.summary)) {
965 drew++;
966 body.appendChild(mdBlock(d.summary, 'crystal-fb-summary'));
967 }
968 if (hasContent(d.sections)) {
969 drew++;
970 var secs = arr(d.sections);
971 for (var i = 0; i < secs.length; i++) {
972 var s = obj(secs[i]);
973 var sec = document.createElement('section');
974 sec.className = 'crystal-fb-sec';
975 if (hasContent(s.heading)) {
976 var h = document.createElement('h3');
977 h.textContent = str(s.heading);
978 sec.appendChild(h);
979 }
980 if (hasContent(s.body)) sec.appendChild(mdBlock(s.body, ''));
981 body.appendChild(sec);
982 }
983 }
984 if (hasContent(d.facts)) {
985 drew++;
986 body.appendChild(fieldHead(tr(opts, 'crystal.field_facts', 'Facts')));
987 var dl = document.createElement('dl');
988 dl.className = 'crystal-fb-facts';
989 var facts = arr(d.facts);
990 for (var fi = 0; fi < facts.length; fi++) {
991 var f = obj(facts[fi]);
992 var dt = document.createElement('dt');
993 dt.textContent = str(f.k);
994 var dd = document.createElement('dd');
995 dd.textContent = str(f.v);
996 dl.appendChild(dt);
997 dl.appendChild(dd);
998 }
999 body.appendChild(dl);
1000 }
1001 if (hasContent(d.open)) {
1002 drew++;
1003 body.appendChild(fieldHead(tr(opts, 'crystal.field_open', 'Open threads')));
1004 var ul = document.createElement('ul');
1005 ul.className = 'crystal-fb-open';
1006 var opens = arr(d.open);
1007 for (var oi = 0; oi < opens.length; oi++) {
1008 var li = document.createElement('li');
1009 li.textContent = str(opens[oi]);
1010 ul.appendChild(li);
1011 }
1012 body.appendChild(ul);
1013 }
1014 if (hasContent(d.links)) {
1015 drew++;
1016 body.appendChild(fieldHead(tr(opts, 'crystal.field_links', 'Links')));
1017 var lu = document.createElement('ul');
1018 lu.className = 'crystal-fb-links';
1019 var links = arr(d.links);
1020 for (var li2 = 0; li2 < links.length; li2++) {
1021 var lk = obj(links[li2]);
1022 var item = document.createElement('li');
1023 item.appendChild(linkEl(str(lk.href), str(lk.label) || str(lk.href), opts));
1024 lu.appendChild(item);
1025 }
1026 body.appendChild(lu);
1027 }
1028
1029 // Everything the schema does not name, INCLUDING a leading-underscore key.
1030 // The channel's own two are put on the copy that goes to the frame and never
1031 // on this one, so an underscore reaching here was written by the model —
1032 // and the reserved namespace is a rule about the wire, not a licence to
1033 // leave a key off the screen. `contentKeys`, which decides what a page must
1034 // prove it drew, is right to skip them: the page is never sent them.
1035 var rest = [];
1036 for (var rk in d) {
1037 if (!own(d, rk)) continue;
1038 if (CORE_KEYS.indexOf(rk) >= 0) continue;
1039 if (hasContent(d[rk])) rest.push(rk);
1040 }
1041 if (rest.length) {
1042 drew++;
1043 var extra = document.createElement('div');
1044 extra.className = 'crystal-fb-extra';
1045 extra.appendChild(fieldHead(tr(opts, 'crystal.other_fields', 'Other fields')));
1046 var note = document.createElement('p');
1047 note.className = 'crystal-fb-extra-note';
1048 note.textContent = tr(opts, 'crystal.other_fields_note',
1049 'Kept as they are, and shown here so nothing vanishes.');
1050 extra.appendChild(note);
1051 for (var xi = 0; xi < rest.length; xi++) {
1052 var box = document.createElement('div');
1053 box.className = 'crystal-fb-field';
1054 var name = document.createElement('div');
1055 name.className = 'crystal-fb-key';
1056 name.textContent = rest[xi];
1057 box.appendChild(name);
1058 box.appendChild(valueEl(d[rest[xi]], 0));
1059 extra.appendChild(box);
1060 }
1061 body.appendChild(extra);
1062 }
1063
1064 if (!drew) {
1065 var empty = document.createElement('div');
1066 empty.className = 'crystal-empty';
1067 empty.textContent = tr(opts, 'crystal.empty',
1068 'The crystal is empty. Steer it below to begin.');
1069 body.appendChild(empty);
1070 }
1071
1072 root.appendChild(body);
1073 el.appendChild(root);
1074 }
1075
1076 /// Why the page is not on screen, and the way back to one that works.
1077 function fallbackNote(opts) {
1078 var note = document.createElement('div');
1079 note.className = 'crystal-fallback-note';
1080 var why = document.createElement('span');
1081 why.className = 'crystal-fallback-why';
1082 why.textContent = (opts.reason === 'partial')
1083 ? tr(opts, 'crystal.page_partial',
1084 'This Diamond\u2019s page did not show everything it holds, so its data is shown instead.')
1085 : tr(opts, 'crystal.page_failed',
1086 'This Diamond\u2019s page did not load, so its data is shown instead.');
1087 note.appendChild(why);
1088 if (typeof opts.onReset === 'function') {
1089 var btn = document.createElement('button');
1090 btn.type = 'button';
1091 btn.className = 'crystal-reset';
1092 btn.textContent = tr(opts, 'crystal.page_reset', 'Reset the page');
1093 btn.addEventListener('click', function () { opts.onReset(); });
1094 note.appendChild(btn);
1095 }
1096 return note;
1097 }
1098
1099 function fieldHead(text) {
1100 var h = document.createElement('h3');
1101 h.className = 'crystal-fb-field-head';
1102 h.textContent = text;
1103 return h;
1104 }
1105
1106 /// Markdown through the app's sanitiser, or plain text where the renderer is
1107 /// not on the page. Either way nothing live reaches the DOM.
1108 function mdBlock(text, cls) {
1109 var div = document.createElement('div');
1110 div.className = ('crystal-fb-md ' + (cls || '')).trim();
1111 if (window.DaimondRender && typeof DaimondRender.md === 'function') {
1112 div.innerHTML = DaimondRender.md(str(text));
1113 } else {
1114 div.textContent = str(text);
1115 }
1116 return div;
1117 }
1118
1119 /// A link that goes out through the app rather than navigating the panel.
1120 function linkEl(href, label, opts) {
1121 var a = document.createElement('a');
1122 a.className = 'crystal-fb-link';
1123 a.textContent = label;
1124 var ok = /^(https?:|mailto:)/i.test(href);
1125 if (!ok) { a.title = href; return a; }
1126 a.href = href;
1127 a.rel = 'noopener noreferrer';
1128 a.addEventListener('click', function (e) {
1129 e.preventDefault();
1130 if (typeof opts.onOpen === 'function') opts.onOpen(href);
1131 });
1132 return a;
1133 }
1134
1135 /// Any value at all, drawn as something a reader can take in. Past four levels
1136 /// it goes out as JSON rather than being flattened — unreadable is recoverable,
1137 /// absent is not.
1138 function valueEl(v, depth) {
1139 var i;
1140 if (typeof v === 'string') return mdBlock(v, '');
1141 if (typeof v === 'number' || typeof v === 'boolean') {
1142 var span = document.createElement('div');
1143 span.className = 'crystal-fb-scalar';
1144 span.textContent = String(v);
1145 return span;
1146 }
1147 if (depth >= 4 || v == null) return jsonEl(v);
1148 if (Array.isArray(v)) {
1149 var ul = document.createElement('ul');
1150 ul.className = 'crystal-fb-list';
1151 for (i = 0; i < v.length; i++) {
1152 var li = document.createElement('li');
1153 li.appendChild(valueEl(v[i], depth + 1));
1154 ul.appendChild(li);
1155 }
1156 return ul;
1157 }
1158 if (typeof v === 'object') {
1159 var dl = document.createElement('dl');
1160 dl.className = 'crystal-fb-map';
1161 for (var k in v) {
1162 if (!own(v, k)) continue;
1163 var dt = document.createElement('dt');
1164 dt.textContent = k;
1165 var dd = document.createElement('dd');
1166 dd.appendChild(valueEl(v[k], depth + 1));
1167 dl.appendChild(dt);
1168 dl.appendChild(dd);
1169 }
1170 return dl;
1171 }
1172 return jsonEl(v);
1173 }
1174
1175 function jsonEl(v) {
1176 var pre = document.createElement('pre');
1177 pre.className = 'crystal-fb-json';
1178 try { pre.textContent = JSON.stringify(v, null, 2); }
1179 catch (e) { pre.textContent = String(v); }
1180 return pre;
1181 }
1182
1183
1184 // ── The shipped page ────────────────────────────────────────────
1185 //
1186 // Every Diamond starts on this, so it is the common case and not a
1187 // placeholder. It is self-contained by necessity as well as by rule: it lives
1188 // in an opaque origin, so there is no stylesheet to link, no font to fetch and
1189 // no network to reach — its own `Content-Security-Policy` says so out loud,
1190 // which is worth having because the sandbox stops the page reading anything of
1191 // ours but does not stop it POSTING somewhere, and the standard page should be
1192 // demonstrably incapable of that.
1193 //
1194 // It speaks the whole channel: `ready`, then `rendered` with the keys it drew,
1195 // then `height` whenever its own height changes, and `open` for every link. It
1196 // renders the core schema and, like the built-in view, every unknown key
1197 // generically — a default page that quietly skipped what it did not recognise
1198 // would trip its own coverage check, and rightly.
1199 //
1200 // It holds no English. The field names arrive in `_labels` and the colours in
1201 // `_theme`; a label that did not arrive is simply not drawn, and its content is
1202 // drawn anyway, because a missing word must never cost a key.
1203 //
1204 // The core key list is repeated inside the page. That is the price of the page
1205 // being self-contained, and it is the right price: a page a model rewrites next
1206 // week cannot import a constant from us either.
1207
1208
1209 /// The theme function as every page written before 2026-08-11 carries it.
1210 ///
1211 /// Verbatim from the `DEFAULT_PAGE` of the day, joined the way that array is joined. It is
1212 /// matched EXACTLY and nothing else is: a page this does not recognise is left completely
1213 /// alone, so a page a model rewrote in its own style is never guessed at.
1214 var THEME_WAS = [
1215 'function theme(t){if(!t)return;var m={bg:"--bg",surface:"--sf",text:"--tx",',
1216 'muted:"--mu",border:"--bd",accent:"--ac",accentText:"--at",font:"--fo",',
1217 'mono:"--mo",size:"--fs",radius:"--rd"};',
1218 'for(var k in m)if(m.hasOwnProperty(k)&&t[k])',
1219 'document.documentElement.style.setProperty(m[k],t[k]);}',
1220 ].join('\n');
1221
1222 /// The same function, applying the palette as a DEFAULT the page can override.
1223 var THEME_NOW = [
1224 'function theme(t){if(!t)return;var m={bg:"--bg",surface:"--sf",text:"--tx",',
1225 'muted:"--mu",border:"--bd",accent:"--ac",accentText:"--at",font:"--fo",',
1226 'mono:"--mo",size:"--fs",radius:"--rd"};',
1227 'var css="";for(var k in m)if(m.hasOwnProperty(k)&&t[k])',
1228 'css+=m[k]+":"+t[k]+";";',
1229 'var el=document.getElementById("dc-theme");',
1230 'if(!el){el=document.createElement("style");el.id="dc-theme";',
1231 'document.head.insertBefore(el,document.head.firstChild);}',
1232 'el.textContent=":root{"+css+"}";}',
1233 ].join('\n');
1234
1235 /// A page brought up to date, or `null` when there is nothing to do.
1236 ///
1237 /// `setProperty` on `documentElement` is an INLINE style, and an inline style beats the
1238 /// page's own `:root{--bg:#fff}` rule every time -- so a page that asked for its own
1239 /// colours was overwritten by the app's palette one message later. Every page written
1240 /// before the fix carries that function, because a page is copied from the default when
1241 /// the Diamond first renders and is the user's own thereafter.
1242 ///
1243 /// A one-line substitution rather than a rewrite: whatever the page has become, only this
1244 /// block changes, and a page that does not contain it byte for byte is returned as `null`
1245 /// and never written.
1246 function upgrade(html) {
1247 var s = String(html == null ? '' : html);
1248 if (s.indexOf(THEME_WAS) === -1) return null;
1249 return s.split(THEME_WAS).join(THEME_NOW);
1250 }
1251
1252 var DEFAULT_PAGE = [
1253 '<!doctype html>',
1254 '<html><head>',
1255 '<meta charset="utf-8">',
1256 '<meta name="viewport" content="width=device-width,initial-scale=1">',
1257 '<meta http-equiv="Content-Security-Policy" content="default-src \'none\';'
1258 + ' script-src \'unsafe-inline\'; style-src \'unsafe-inline\'; img-src data:; font-src data:">',
1259 '<style>',
1260 ':root{--bg:transparent;--sf:rgba(128,128,128,.10);--tx:#777;--mu:#999;',
1261 '--bd:rgba(128,128,128,.35);--ac:#4a7fd0;--at:#4a7fd0;',
1262 '--fo:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;',
1263 '--mo:ui-monospace,SFMono-Regular,Consolas,monospace;--fs:14px;--rd:8px}',
1264 '*{box-sizing:border-box}',
1265 'html,body{background:transparent;margin:0;padding:0}',
1266 'body{font-family:var(--fo);font-size:var(--fs);line-height:1.6;color:var(--tx);',
1267 'word-break:break-word;overflow-wrap:anywhere;padding:0 0 2px}',
1268 'h1{font-size:1.45em;line-height:1.25;font-weight:650;margin:0 0 .5em}',
1269 'h2{font-size:1.08em;line-height:1.35;font-weight:650;margin:1.5em 0 .45em}',
1270 'h3{font-size:1em;font-weight:650;margin:1.1em 0 .35em}',
1271 'h1:first-child,h2:first-child,h3:first-child{margin-top:0}',
1272 'p{margin:0 0 .8em}',
1273 'a{color:var(--at);text-decoration:underline;text-underline-offset:2px;cursor:pointer}',
1274 'code{font-family:var(--mo);font-size:.92em;background:var(--sf);',
1275 'border:1px solid var(--bd);border-radius:4px;padding:.05em .3em}',
1276 'pre{font-family:var(--mo);font-size:.9em;background:var(--sf);',
1277 'border:1px solid var(--bd);border-radius:var(--rd);padding:10px 12px;',
1278 'overflow-x:auto;margin:0 0 .85em;line-height:1.5}',
1279 'pre code{background:none;border:0;padding:0;font-size:1em}',
1280 'ul,ol{margin:0 0 .8em;padding-left:1.25em}',
1281 'li{margin:.12em 0}',
1282 'blockquote{margin:0 0 .8em;padding:0 0 0 .85em;border-left:2px solid var(--bd);color:var(--mu)}',
1283 'img{max-width:100%;height:auto;border-radius:var(--rd)}',
1284 '.facts{display:grid;grid-template-columns:auto 1fr;gap:.25em .9em;margin:0 0 .85em}',
1285 '.facts .k{color:var(--mu);font-size:.93em}',
1286 '.facts .v{min-width:0}',
1287 '.field{border:1px solid var(--bd);border-radius:var(--rd);padding:9px 11px;margin:0 0 .7em;background:var(--sf)}',
1288 '.field > .k{color:var(--mu);font-family:var(--mo);font-size:.85em;margin:0 0 .4em}',
1289 '.field > :last-child{margin-bottom:0}',
1290 '.note{color:var(--mu);font-size:.9em;margin:0 0 .7em}',
1291 '.empty{color:var(--mu);font-style:italic}',
1292 '@media (max-width:420px){.facts{grid-template-columns:1fr;gap:0}',
1293 '.facts .k{margin-top:.45em}}',
1294 '</style></head><body><div id="r"></div><script>',
1295 '(function(){',
1296 'var CORE=["title","summary","sections","facts","open","links"];',
1297 'var R=document.getElementById("r"),D={},L={},last=-1;',
1298 'function post(o){o.dc=1;o.v=1;parent.postMessage(o,"*");}',
1299 'function esc(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;")',
1300 '.replace(/>/g,"&gt;").replace(/"/g,"&quot;");}',
1301 'function has(v){if(v==null)return false;',
1302 'if(typeof v==="string")return v.trim()!=="";',
1303 'if(typeof v==="number"||typeof v==="boolean")return true;',
1304 'if(Object.prototype.toString.call(v)==="[object Array]")return v.length>0;',
1305 'if(typeof v==="object"){for(var k in v)if(v.hasOwnProperty(k))return true;return false;}',
1306 'return false;}',
1307 // Links never navigate: the parent decides, because it is the only side
1308 // that can put the question to a person.
1309 'function anch(h,txt){return /^(https?:|mailto:)/i.test(h)',
1310 '?"<a data-h=\\""+h+"\\">"+txt+"</a>":txt;}',
1311 'function inl(s){s=esc(s);',
1312 's=s.replace(/`([^`]+)`/g,function(m,a){return "<code>"+a+"</code>";});',
1313 's=s.replace(/!\\[([^\\]]*)\\]\\((data:image\\/[^)\\s]+)\\)/g,',
1314 'function(m,a,b){return "<img alt=\\""+a+"\\" src=\\""+b+"\\">";});',
1315 's=s.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g,function(m,a,b){return anch(b,a);});',
1316 's=s.replace(/\\*\\*([^*]+)\\*\\*/g,"<strong>$1</strong>");',
1317 's=s.replace(/(^|[^*])\\*([^*\\n]+)\\*/g,"$1<em>$2</em>");',
1318 'return s;}',
1319 // Enough markdown for what a reducer writes: paragraphs, headings, lists,
1320 // quotes and fenced code. A `##` inside a fence stays inside it.
1321 'function md(src){var L2=String(src).split("\\n"),i=0,o="";',
1322 'while(i<L2.length){var l=L2[i];',
1323 'var f=/^ {0,3}(`{3,}|~{3,})/.exec(l);',
1324 'if(f){var c=f[1].charAt(0),n=f[1].length,b=[];i++;',
1325 'var re=new RegExp("^ {0,3}"+(c==="`"?"`":"~")+"{"+n+",}\\\\s*$");',
1326 'while(i<L2.length&&!re.test(L2[i])){b.push(L2[i]);i++;}i++;',
1327 'o+="<pre><code>"+esc(b.join("\\n"))+"</code></pre>";continue;}',
1328 'if(/^\\s*$/.test(l)){i++;continue;}',
1329 'var hm=/^ {0,3}(#{1,6})\\s+(.*)$/.exec(l);',
1330 'if(hm){var lv=Math.min(4,hm[1].length+2);',
1331 'o+="<h"+lv+">"+inl(hm[2])+"</h"+lv+">";i++;continue;}',
1332 'if(/^ {0,3}>\\s?/.test(l)){var q=[];',
1333 'while(i<L2.length&&/^ {0,3}>\\s?/.test(L2[i])){q.push(L2[i].replace(/^ {0,3}>\\s?/,""));i++;}',
1334 'o+="<blockquote>"+md(q.join("\\n"))+"</blockquote>";continue;}',
1335 'var LI=/^ {0,3}([-*+]|\\d+[.)])\\s+/;',
1336 'if(LI.test(l)){var ord=/^ {0,3}\\d/.test(l),it=[];',
1337 'while(i<L2.length&&LI.test(L2[i])){it.push("<li>"+inl(L2[i].replace(LI,""))+"</li>");i++;}',
1338 'o+=(ord?"<ol>":"<ul>")+it.join("")+(ord?"</ol>":"</ul>");continue;}',
1339 'var p=[];',
1340 'while(i<L2.length&&!/^\\s*$/.test(L2[i])&&!/^ {0,3}(`{3,}|~{3,})/.test(L2[i])',
1341 '&&!LI.test(L2[i])&&!/^ {0,3}>\\s?/.test(L2[i])&&!/^ {0,3}#{1,6}\\s/.test(L2[i]))',
1342 '{p.push(L2[i]);i++;}',
1343 'o+="<p>"+inl(p.join("\\n")).replace(/\\n/g,"<br>")+"</p>";}',
1344 'return o;}',
1345 // Anything at all, so a key the reducer invented is still on screen.
1346 'function val(v,d){',
1347 'if(typeof v==="string")return md(v);',
1348 'if(typeof v==="number"||typeof v==="boolean")return "<p>"+esc(v)+"</p>";',
1349 'if(v==null||d>=4)return "<pre>"+esc(JSON.stringify(v,null,2))+"</pre>";',
1350 'if(Object.prototype.toString.call(v)==="[object Array]"){var o="<ul>";',
1351 'for(var i=0;i<v.length;i++)o+="<li>"+val(v[i],d+1)+"</li>";return o+"</ul>";}',
1352 'if(typeof v==="object"){var o2="";',
1353 'for(var k in v){if(!v.hasOwnProperty(k))continue;',
1354 'o2+="<div class=\\"field\\"><div class=\\"k\\">"+esc(k)+"</div>"+val(v[k],d+1)+"</div>";}',
1355 'return o2;}',
1356 'return "<pre>"+esc(String(v))+"</pre>";}',
1357 // The palette arrives as DEFAULTS THE PAGE MAY OVERRIDE, written into a style
1358 // element at the top of the cascade -- not as inline properties on :root.
1359 // setProperty on documentElement is an inline style, and an inline style beats
1360 // the page's own `:root{--bg:#fff}` rule every time. A user asked for a white
1361 // background, the daimon set --bg and the app overwrote it on the next data
1362 // message, so the widget it added in the same turn worked and the colour did
1363 // not. A theme is what the page starts from, not what it is held to.
1364 'function theme(t){if(!t)return;var m={bg:"--bg",surface:"--sf",text:"--tx",',
1365 'muted:"--mu",border:"--bd",accent:"--ac",accentText:"--at",font:"--fo",',
1366 'mono:"--mo",size:"--fs",radius:"--rd"};',
1367 'var css="";for(var k in m)if(m.hasOwnProperty(k)&&t[k])',
1368 'css+=m[k]+":"+t[k]+";";',
1369 'var el=document.getElementById("dc-theme");',
1370 'if(!el){el=document.createElement("style");el.id="dc-theme";',
1371 'document.head.insertBefore(el,document.head.firstChild);}',
1372 'el.textContent=":root{"+css+"}";}',
1373 'function render(){var h="",keys=[],i;',
1374 'if(has(D.title)){keys.push("title");h+="<h1>"+esc(D.title)+"</h1>";}',
1375 'if(has(D.summary)){keys.push("summary");h+=md(D.summary);}',
1376 'if(has(D.sections)){keys.push("sections");',
1377 'for(i=0;i<D.sections.length;i++){var s=D.sections[i]||{};',
1378 'if(has(s.heading))h+="<h2>"+esc(s.heading)+"</h2>";',
1379 'if(has(s.body))h+=md(s.body);}}',
1380 'if(has(D.facts)){keys.push("facts");',
1381 'if(L.facts)h+="<h2>"+esc(L.facts)+"</h2>";h+="<div class=\\"facts\\">";',
1382 'for(i=0;i<D.facts.length;i++){var ft=D.facts[i]||{};',
1383 'h+="<div class=\\"k\\">"+esc(ft.k==null?"":ft.k)+"</div>";',
1384 'h+="<div class=\\"v\\">"+inl(ft.v==null?"":ft.v)+"</div>";}h+="</div>";}',
1385 'if(has(D.open)){keys.push("open");',
1386 'if(L.open)h+="<h2>"+esc(L.open)+"</h2>";h+="<ul>";',
1387 'for(i=0;i<D.open.length;i++)h+="<li>"+inl(D.open[i]==null?"":D.open[i])+"</li>";',
1388 'h+="</ul>";}',
1389 'if(has(D.links)){keys.push("links");',
1390 'if(L.links)h+="<h2>"+esc(L.links)+"</h2>";h+="<ul>";',
1391 'for(i=0;i<D.links.length;i++){var lk=D.links[i]||{};',
1392 'var hr=esc(lk.href==null?"":lk.href);',
1393 'var lb=esc(lk.label==null||lk.label===""?(lk.href==null?"":lk.href):lk.label);',
1394 'h+="<li>"+anch(hr,lb)+"</li>";}h+="</ul>";}',
1395 'var xs=[];for(var k in D){if(!D.hasOwnProperty(k))continue;',
1396 'if(k.charAt(0)==="_")continue;if(CORE.indexOf(k)>=0)continue;',
1397 'if(!has(D[k]))continue;xs.push(k);}',
1398 'if(xs.length){if(L.other)h+="<h2>"+esc(L.other)+"</h2>";',
1399 'if(L.other_note)h+="<div class=\\"note\\">"+esc(L.other_note)+"</div>";',
1400 'for(i=0;i<xs.length;i++){keys.push(xs[i]);',
1401 'h+="<div class=\\"field\\"><div class=\\"k\\">"+esc(xs[i])+"</div>"',
1402 '+val(D[xs[i]],1)+"</div>";}}',
1403 'if(!h&&L.empty)h="<div class=\\"empty\\">"+esc(L.empty)+"</div>";',
1404 'R.innerHTML=h;post({cmd:"rendered",keys:keys});measure();}',
1405 'function measure(){var px=Math.ceil(Math.max(document.body.scrollHeight,',
1406 'R.getBoundingClientRect().height))+2;',
1407 'if(Math.abs(px-last)<2)return;last=px;post({cmd:"height",px:px});}',
1408 'addEventListener("message",function(e){if(e.source!==parent)return;',
1409 'var m=e.data;if(!m||m.dc!==1||m.v!==1)return;',
1410 'if(m.cmd==="data"){D=m.data||{};L=D._labels||{};theme(D._theme);render();}});',
1411 'document.addEventListener("click",function(e){var a=e.target;',
1412 'while(a&&a!==document.body&&a.tagName!=="A")a=a.parentNode;',
1413 'if(!a||a.tagName!=="A")return;e.preventDefault();',
1414 'var h=a.getAttribute("data-h")||"";if(h)post({cmd:"open",href:h});});',
1415 'if(window.ResizeObserver)new ResizeObserver(measure).observe(document.body);',
1416 'else addEventListener("resize",measure);',
1417 'post({cmd:"ready"});',
1418 '})();',
1419 '<\/script></body></html>',
1420 '',
1421 ].join('\n');
1422
1423
1424 // ── Export ──────────────────────────────────────────────────────
1425
1426 window.DaimondCrystal = {
1427 CORE_KEYS: CORE_KEYS,
1428 DEFAULT_PAGE: DEFAULT_PAGE,
1429 upgrade: upgrade,
1430 FALLBACK_MS: FALLBACK_MS,
1431 PROTOCOL: PROTOCOL,
1432 parse: parse,
1433 toMarkdown: toMarkdown,
1434 fromMarkdown: fromMarkdown,
1435 mount: mount,
1436 unmount: unmount,
1437 fallback: fallback,
1438 /// The policy every page is served under, so a verifier can assert the exact
1439 /// string rather than keeping a copy of it that can drift.
1440 PAGE_CSP: PAGE_CSP,
1441 /// What is on screen, for a verifier: whether the page or the built-in view
1442 /// is up, why, which keys the page claimed, and where the policy was put in
1443 /// the page — `'head'`, `'html'`, `'doctype'` or `'start'`, with `carried`
1444 /// saying whether the author had already declared one. Never used by the app.
1445 _state: function () {
1446 if (!live) {
1447 return { mode: 'none', ready: false, reason: '', keys: [], height: 0, csp: null };
1448 }
1449 if (live.done) {
1450 return {
1451 mode: 'fallback',
1452 ready: true,
1453 reason: str(live.reason),
1454 keys: (live.keys || []).slice(),
1455 height: 0,
1456 csp: live.csp || null,
1457 };
1458 }
1459 return {
1460 mode: 'frame',
1461 ready: !!live.ready,
1462 reason: '',
1463 keys: (live.keys || []).slice(),
1464 height: live.height || 0,
1465 csp: live.csp || null,
1466 };
1467 },
1468 };
1469})();