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,"&").replace(/</g,"<")', |
| 1300 | '.replace(/>/g,">").replace(/"/g,""");}', |
| 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 | })(); |