mockpit 1.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +66 -202
  2. package/bin/demoData.js +5 -5
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +687 -192
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +110 -19
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +563 -41
  10. package/dist/server/apiViews.js +223 -74
  11. package/dist/server/app.d.ts +10 -2
  12. package/dist/server/app.js +3284 -782
  13. package/dist/server/appIcon.js +15 -5
  14. package/dist/server/appIconPng.js +3 -3
  15. package/dist/server/askNudges.js +1 -1
  16. package/dist/server/cdnInline.d.ts +8 -0
  17. package/dist/server/cdnInline.js +638 -0
  18. package/dist/server/codeWorker.js +2 -1
  19. package/dist/server/decisionList.d.ts +45 -0
  20. package/dist/server/decisionList.js +302 -0
  21. package/dist/server/decisions.d.ts +8 -8
  22. package/dist/server/decisions.js +18 -17
  23. package/dist/server/designGuide.d.ts +10 -6
  24. package/dist/server/designGuide.js +173 -78
  25. package/dist/server/embeds.d.ts +4 -1
  26. package/dist/server/embeds.js +57 -6
  27. package/dist/server/eventLog.d.ts +2 -2
  28. package/dist/server/eventLog.js +11 -11
  29. package/dist/server/events.d.ts +9 -6
  30. package/dist/server/feedbackBatch.d.ts +38 -16
  31. package/dist/server/feedbackBatch.js +121 -50
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/guidance.d.ts +23 -0
  35. package/dist/server/guidance.js +71 -0
  36. package/dist/server/handoff.d.ts +4 -1
  37. package/dist/server/handoff.js +8 -2
  38. package/dist/server/icons.js +1 -1
  39. package/dist/server/index.js +49 -11
  40. package/dist/server/jump.d.ts +21 -0
  41. package/dist/server/jump.js +175 -0
  42. package/dist/server/knobs.d.ts +3 -3
  43. package/dist/server/knobs.js +6 -6
  44. package/dist/server/mcpHttp.d.ts +8 -1
  45. package/dist/server/mcpHttp.js +44 -7
  46. package/dist/server/mcpSpec.d.ts +2 -1
  47. package/dist/server/mcpSpec.js +172 -50
  48. package/dist/server/parts.js +9 -1
  49. package/dist/server/postSurfaces.js +3 -0
  50. package/dist/server/run.d.ts +1 -1
  51. package/dist/server/run.js +31 -17
  52. package/dist/server/runApi.d.ts +3 -3
  53. package/dist/server/runApi.js +34 -24
  54. package/dist/server/sqlStore.d.ts +117 -94
  55. package/dist/server/sqlStore.js +1089 -141
  56. package/dist/server/standings.d.ts +25 -0
  57. package/dist/server/standings.js +113 -0
  58. package/dist/server/surfacePage.d.ts +6 -9
  59. package/dist/server/surfacePage.js +361 -86
  60. package/dist/server/types.d.ts +252 -69
  61. package/dist/server/types.js +87 -69
  62. package/extensions/mockpit.js +245 -65
  63. package/guide/AGENT_SETUP.md +13 -1
  64. package/guide/packs/design-craft.md +34 -0
  65. package/guide/topics/asks.md +117 -69
  66. package/guide/topics/feedback.md +156 -119
  67. package/guide/topics/html.md +19 -32
  68. package/guide/topics/http.md +43 -40
  69. package/guide/topics/knobs.md +7 -10
  70. package/guide/topics/scripts.md +24 -35
  71. package/guide/topics/surfaces.md +50 -41
  72. package/opencode/mockpit.js +120 -0
  73. package/opencode/package.json +8 -0
  74. package/package.json +4 -6
  75. package/skills/launch-video/SKILL.md +12 -12
  76. package/skills/mockpit/SKILL.md +35 -23
  77. package/viewer/dist/index.html +9 -8
@@ -2,6 +2,7 @@ import { scanFills, slotCss } from "./fills.js";
2
2
  import { CDN_ALLOWLIST, checkCdnUrl } from "./cdn.js";
3
3
  import { CORE_CSS, KITS, kitAssets } from "./kits.js";
4
4
  import { expandIcons } from "./icons.js";
5
+ import { PARAGRAPH_TEXT_MAX, PARAGRAPHS_MAX } from "./types.js";
5
6
  import { schemeCss, themeById, tokenThemeCss, viewerThemeCss, } from "./themes.js";
6
7
  // The kit's two custom SVG accent ramps (teal, coral) aren't in the theme
7
8
  // palette, so they carry their own light/dark values. Like the theme tokens
@@ -34,17 +35,11 @@ const cdns = CDN_ALLOWLIST.join(" ");
34
35
  // (served at <origin>/a/:id) embed by URL. It is needed because the iframe runs
35
36
  // at an opaque origin (sandbox without allow-same-origin), so `'self'` matches
36
37
  // nothing, and a local http origin isn't covered by the `https:` source.
37
- // `${origin}/asset/` is the fixed, content-hashed path the bridge script and the
38
- // static stylesheets are served from (see registerAsset). It is deliberately a
39
- // single directory of server-authored, workspace-data-free files: nothing an
40
- // agent or a user can write is ever reachable under it, so widening script-src
41
- // to it grants the sandbox no capability beyond running our own bridge.
42
38
  function buildCsp(origin) {
43
- const assets = `${origin}${STATIC_ASSET_PREFIX}`;
44
39
  return [
45
40
  `default-src 'none'`,
46
- `script-src 'unsafe-inline' ${assets} ${cdns}`,
47
- `style-src 'unsafe-inline' ${assets} ${cdns}`,
41
+ `script-src 'unsafe-inline' ${cdns}`,
42
+ `style-src 'unsafe-inline' ${cdns}`,
48
43
  `font-src ${cdns} data:`,
49
44
  `img-src https: data: blob: ${origin}`,
50
45
  `connect-src ${cdns}`,
@@ -264,6 +259,140 @@ if (window.ResizeObserver) {
264
259
  if (document.body) window.__ssRO.observe(document.body);
265
260
  }
266
261
  `;
262
+ // Freeze: a frame shown small (a wall, a grid, a cover, an option's picture) stops animating
263
+ // once its page has settled, so a screenful of them costs nothing while they're looked at.
264
+ // Opt-in by the fragment `#small`, so it's the same document and the same cache entry as the
265
+ // page on Stage, which never has it. It runs first in <head>, ahead of the page's own
266
+ // scripts, and asks nothing of them. Pinned down with pages built to trip it.
267
+ //
268
+ // The danger is a picture that freezes half-drawn, so stopping is the last thing that happens
269
+ // to a page, and each way of drawing is handled for what it is:
270
+ //
271
+ // - Settled: the page has loaded, and for QUIET_MS the DOM hasn't changed, no animation
272
+ // with an end is still running (its delay counts), and no frame has been asked for.
273
+ // An animation that loops forever doesn't hold this up: it never finishes.
274
+ // - Or CAP_MS have gone by. A frame loop can't be told apart from a page drawing in slowly
275
+ // (it may draw on a canvas or in a shadow root, neither of which the DOM check sees), so
276
+ // every loop gets that long, and the cap is where its cost ends. A draw-in longer than
277
+ // the cap is the one case that freezes part way.
278
+ // - Time here is time the frame was rendered: this clock ticks on animation frames, which a
279
+ // hidden or offscreen frame isn't given. A page that hasn't been seen hasn't had its
280
+ // chance to draw, so it doesn't get frozen before it has.
281
+ // - At the freeze, what has an end is finished rather than paused, since its end is what
282
+ // the page means to show. What loops is paused back at its start, its resting state in
283
+ // nearly every loop, and never midway through a fade. New animations get the same.
284
+ // - Frames stop. A frame loop's next frame is held and never handed out. A timer or event
285
+ // asking for a frame is the page doing something new, not a loop, so it thaws the page
286
+ // and settling starts over, within RUN_MAX_MS of rendered time in all, so a timer can't
287
+ // keep it awake. Timers themselves keep running: drawing by timer completes however late.
288
+ // Past that allowance only HELD_MAX requests are kept for a later thaw: a page asking by
289
+ // timer would otherwise pile them up for as long as it stays open.
290
+ // - New knob values always thaw, with a new allowance: the picture must follow them.
291
+ export const FREEZE_JS = `
292
+ (function () {
293
+ if (location.hash !== '#small' || !document.getAnimations) return;
294
+ var QUIET_MS = 800, CAP_MS = 10000, LOAD_CAP_MS = 15000, MAX_STEP_MS = 100, RUN_MAX_MS = 30000;
295
+ var HELD_MAX = 32;
296
+ var raf = window.requestAnimationFrame.bind(window);
297
+ var caf = window.cancelAnimationFrame.bind(window);
298
+ var asked = new Map(), nextId = 0;
299
+ var frozen = false, loaded = false;
300
+ var run = 0, last = 0, settleFrom = 0, active = 0, runUntil = RUN_MAX_MS;
301
+ var paused = [], sweepQueued = false;
302
+ window.addEventListener('load', function () { loaded = true; settleFrom = run; active = run; });
303
+
304
+ function fire(id, t) {
305
+ var entry = asked.get(id);
306
+ if (!entry) return;
307
+ entry.native = 0;
308
+ if (frozen) return;
309
+ asked.delete(id);
310
+ entry.cb(t);
311
+ }
312
+ function queue(id, entry) {
313
+ entry.native = raf(function (t) { fire(id, t); });
314
+ }
315
+ window.requestAnimationFrame = function (cb) {
316
+ if (frozen && run < runUntil) thaw();
317
+ var id = ++nextId, entry = { cb: cb, native: 0 };
318
+ if (frozen && asked.size >= HELD_MAX) return id;
319
+ asked.set(id, entry);
320
+ active = run;
321
+ if (!frozen) queue(id, entry);
322
+ return id;
323
+ };
324
+ window.cancelAnimationFrame = function (id) {
325
+ var entry = asked.get(id);
326
+ if (!entry) return;
327
+ if (entry.native) caf(entry.native);
328
+ asked.delete(id);
329
+ };
330
+ if (window.webkitRequestAnimationFrame) {
331
+ window.webkitRequestAnimationFrame = window.requestAnimationFrame;
332
+ window.webkitCancelAnimationFrame = window.cancelAnimationFrame;
333
+ }
334
+
335
+ function own(a) { return a.timeline === document.timeline && a.effect; }
336
+ function ends(a) { return isFinite(a.effect.getComputedTiming().endTime); }
337
+ function stillDrawing() {
338
+ return document.getAnimations().some(function (a) {
339
+ return own(a) && a.playState === 'running' && ends(a);
340
+ });
341
+ }
342
+ function sweep() {
343
+ sweepQueued = false;
344
+ if (!frozen) return;
345
+ document.getAnimations().forEach(function (a) {
346
+ if (!own(a)) return;
347
+ try {
348
+ if (ends(a)) a.finish();
349
+ else if (a.playState !== 'paused') { a.pause(); a.currentTime = 0; paused.push(a); }
350
+ } catch (e) {}
351
+ });
352
+ Array.prototype.forEach.call(document.querySelectorAll('svg'), function (svg) {
353
+ if (svg.pauseAnimations) svg.pauseAnimations();
354
+ });
355
+ }
356
+ function queueSweep() {
357
+ if (frozen && !sweepQueued) { sweepQueued = true; setTimeout(sweep, 0); }
358
+ }
359
+ function freeze() { frozen = true; sweep(); }
360
+ function thaw() {
361
+ frozen = false;
362
+ active = settleFrom = run;
363
+ paused.splice(0).forEach(function (a) { try { a.play(); } catch (e) {} });
364
+ Array.prototype.forEach.call(document.querySelectorAll('svg'), function (svg) {
365
+ if (svg.unpauseAnimations) svg.unpauseAnimations();
366
+ });
367
+ asked.forEach(function (entry, id) { if (!entry.native) queue(id, entry); });
368
+ last = 0;
369
+ raf(tick);
370
+ }
371
+ function settled() {
372
+ return run - active >= QUIET_MS && !stillDrawing();
373
+ }
374
+ function tick(t) {
375
+ if (frozen) return;
376
+ run += last ? Math.min(t - last, MAX_STEP_MS) : 0;
377
+ last = t;
378
+ if (loaded ? settled() || run - settleFrom >= CAP_MS : run >= LOAD_CAP_MS) freeze();
379
+ else raf(tick);
380
+ }
381
+ new MutationObserver(function () {
382
+ active = run;
383
+ queueSweep();
384
+ }).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
385
+ document.addEventListener('animationstart', queueSweep, true);
386
+ document.addEventListener('transitionstart', queueSweep, true);
387
+ window.addEventListener('message', function (e) {
388
+ var d = e.data;
389
+ if (e.source !== window.parent || !d || d.__mockpit !== true || d.type !== 'knobs') return;
390
+ runUntil = run + RUN_MAX_MS;
391
+ if (frozen) thaw();
392
+ });
393
+ raf(tick);
394
+ })();
395
+ `;
267
396
  // Hit test: the viewer's marker overlay lives in the trusted origin ABOVE the
268
397
  // sandboxed frame, so when the operator drops a pin it knows the normalized
269
398
  // point but nothing about what sits under it. This answers that question over
@@ -378,7 +507,7 @@ export const HIT_TEST_JS = `
378
507
  // goto {version, to} a click on [data-goto] while the host
379
508
  // has Try on (PARTS_JS); to is the
380
509
  // attribute as written ("next", "prev",
381
- // "<mock>:<state>"), ≤200 chars. The host
510
+ // "<design>:<state>"), ≤200 chars. The host
382
511
  // honours it only while Try is on, from
383
512
  // the frame on stage at its version, and
384
513
  // only to a screen of this project:
@@ -387,6 +516,14 @@ export const HIT_TEST_JS = `
387
516
  // a drag never reports
388
517
  // ready {version} the knob runtime is listening (KNOBS_JS,
389
518
  // from <head>); values can be sent now
519
+ // paragraphs {version, blocks, width} a written surface's blocks (PARAGRAPHS_JS),
520
+ // only in a document opened with #read;
521
+ // blocks[i] = {i, kind, text, box:{x,y,w,h}}: place in the document, tag, clipped
522
+ // text, document px. Sent on load, on 50/150/400/1000ms timers and on any layout
523
+ // change; never twice alike. `version` as on a parts report.
524
+ // paragraph-tap {version, i} a click or tap on block i, which only opens
525
+ // the host's comment field; the host reads
526
+ // the text from its own last report
390
527
  // pinch {phase, a:{x,y}, b:{x,y}} two fingers on the page, in viewport px
391
528
  // zoom {x, y, dy} ⌘/Ctrl+scroll or a trackpad pinch, in viewport px
392
529
  // (PARTS_JS); phase start|move|end. The
@@ -408,8 +545,10 @@ export const HIT_TEST_JS = `
408
545
  // try {on} Try is on or off: while on, [data-goto] elements are
409
546
  // outlined and a click on one reports goto instead
410
547
  // of reaching the page; a new document starts off
548
+ // paragraph-marks {marked:[i], on:i|null} tint these blocks of a #read document: the
549
+ // ones holding comments, and the one being commented on
411
550
  //
412
- // Knobs (`/s/:id?k=`, validated by the route against the mock's declared knobs,
551
+ // Knobs (`/s/:id?k=`, validated by the route against the design's declared knobs,
413
552
  // and the `knobs` command): for each path, dots become "-" in names, and
414
553
  // --k-<path> on <html style>: numbers as-is, booleans 1/0, strings
415
554
  // raw when they are plain CSS tokens, else a quoted CSS
@@ -687,6 +826,161 @@ export const PARTS_JS = `
687
826
  });
688
827
  })();
689
828
  `;
829
+ // A written surface read as a document. Identity and geometry leave the frame as
830
+ // data, the way parts do; the trusted viewer draws its pins from those numbers and
831
+ // shows the text as text nodes. Opt-in by the fragment `#read`, so the same document
832
+ // pictured on a card or stacked on the stage stays inert: a fragment reaches no
833
+ // server and no render cache, and it is read before first paint, so the reading type
834
+ // never flashes. A rich document is not scrollable in this mode: the host sizes the
835
+ // frame to it and the reader is the page's one scroll.
836
+ const READ_CSS = `
837
+ html.mockpit-read,html.mockpit-read body{overflow:hidden}
838
+ html.mockpit-read body{display:flow-root;box-sizing:border-box;padding:2px 28px 6px;font-size:16px;line-height:1.65;text-wrap:pretty}
839
+ html.mockpit-read :is(p,ul,ol,blockquote,table){margin:.75em 0}
840
+ html.mockpit-read table{display:block;max-width:100%;overflow-x:auto}
841
+ html.mockpit-read pre{max-height:none}
842
+ html.mockpit-read [data-mp]{border-radius:6px;cursor:pointer;transition:background-color .12s ease-out}
843
+ html.mockpit-read .mockpit-p-hov{background-color:var(--hover);box-shadow:0 0 0 4px var(--hover)}
844
+ html.mockpit-read .mockpit-p-mark{--t:color-mix(in srgb,var(--accent) 12%,transparent);background-color:var(--t);box-shadow:0 0 0 4px var(--t)}
845
+ html.mockpit-read .mockpit-p-on{--t:color-mix(in srgb,var(--accent) 24%,transparent);background-color:var(--t);box-shadow:0 0 0 4px var(--t)}
846
+ `;
847
+ export const PARAGRAPHS_JS = `
848
+ (function () {
849
+ if (window.parent === window || location.hash !== '#read') return;
850
+ var MAX_BLOCKS = ${PARAGRAPHS_MAX};
851
+ var MAX_TEXT = ${PARAGRAPH_TEXT_MAX};
852
+ // Outermost blocks only: a paragraph in a quote or a list item belongs to it. A
853
+ // list inside a list item is the one exception, so a sub-item can be commented on.
854
+ var BLOCKS = 'h1,h2,h3,h4,h5,h6,p,li,blockquote,pre,table,figure,dl';
855
+ var html = document.documentElement;
856
+ var version = Number(html.getAttribute('data-mockpit-version')) || null;
857
+ html.classList.add('mockpit-read');
858
+ var sheet = document.createElement('style');
859
+ sheet.textContent = ${JSON.stringify(READ_CSS)};
860
+ document.head.appendChild(sheet);
861
+
862
+ function clean(t) { return String(t || '').replace(/\\s+/g, ' ').trim().slice(0, MAX_TEXT); }
863
+ function textOf(el) {
864
+ var from = el;
865
+ if (el.tagName === 'LI' && el.querySelector('ul,ol')) {
866
+ from = el.cloneNode(true);
867
+ var inner = from.querySelectorAll('ul,ol');
868
+ for (var i = 0; i < inner.length; i++) inner[i].remove();
869
+ }
870
+ var t = clean(from.textContent);
871
+ if (t) return t;
872
+ var pic = el.querySelector('img');
873
+ return pic ? clean(pic.getAttribute('alt')) || 'image' : '';
874
+ }
875
+ function found() {
876
+ // A mermaid page: the whole diagram is one block, once it has drawn.
877
+ var diagram = document.getElementById('m');
878
+ if (diagram) return diagram.firstChild ? [{ el: diagram, kind: 'diagram' }] : [];
879
+ var all = document.querySelectorAll(BLOCKS);
880
+ var out = [];
881
+ for (var i = 0; i < all.length && out.length < MAX_BLOCKS; i++) {
882
+ var el = all[i];
883
+ var up = el.parentElement && el.parentElement.closest(BLOCKS);
884
+ if (up && !(el.tagName === 'LI' && up.tagName === 'LI')) continue;
885
+ out.push({ el: el, kind: el.tagName.toLowerCase() });
886
+ }
887
+ return out.length || !document.body ? out : [{ el: document.body, kind: 'document' }];
888
+ }
889
+ function collect() {
890
+ var sx = window.scrollX || 0, sy = window.scrollY || 0;
891
+ var list = found();
892
+ var blocks = [];
893
+ for (var i = 0; i < list.length; i++) {
894
+ var el = list[i].el;
895
+ var t = textOf(el);
896
+ if (!t) continue;
897
+ el.setAttribute('data-mp', String(blocks.length));
898
+ var r = el.getBoundingClientRect();
899
+ blocks.push({
900
+ i: blocks.length,
901
+ kind: list[i].kind,
902
+ text: t,
903
+ box: { x: r.left + sx, y: r.top + sy, w: r.width, h: r.height },
904
+ });
905
+ }
906
+ return { version: version, blocks: blocks, width: innerWidth };
907
+ }
908
+ var last = '';
909
+ var timer = 0;
910
+ function schedule() {
911
+ if (timer) return;
912
+ timer = setTimeout(function () {
913
+ timer = 0;
914
+ var m = collect();
915
+ var key = JSON.stringify(m);
916
+ if (key === last) return;
917
+ last = key;
918
+ m.__mockpit = true;
919
+ m.type = 'paragraphs';
920
+ parent.postMessage(m, '*');
921
+ }, 0);
922
+ }
923
+ if (typeof ResizeObserver !== 'undefined') {
924
+ var ro = new ResizeObserver(schedule);
925
+ ro.observe(html);
926
+ if (document.body) ro.observe(document.body);
927
+ }
928
+ // No attributes: this script writes those, and only content arriving moves blocks.
929
+ new MutationObserver(schedule).observe(html, { subtree: true, childList: true, characterData: true });
930
+ window.addEventListener('resize', schedule);
931
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(schedule);
932
+ document.addEventListener('load', schedule, true);
933
+ function settle() {
934
+ schedule();
935
+ [50, 150, 400, 1000].forEach(function (ms) { setTimeout(schedule, ms); });
936
+ }
937
+ if (document.readyState === 'complete') settle();
938
+ else window.addEventListener('load', settle);
939
+
940
+ var hovered = null;
941
+ function hover(el) {
942
+ if (el === hovered) return;
943
+ if (hovered) hovered.classList.remove('mockpit-p-hov');
944
+ hovered = el;
945
+ if (el) el.classList.add('mockpit-p-hov');
946
+ }
947
+ document.addEventListener('pointerover', function (e) {
948
+ if (e.pointerType !== 'mouse') return;
949
+ hover(e.target && e.target.closest ? e.target.closest('[data-mp]') : null);
950
+ }, true);
951
+ html.addEventListener('pointerleave', function () { hover(null); });
952
+ document.addEventListener('click', function (e) {
953
+ var t = e.target;
954
+ if (!t || !t.closest || t.closest('a[href],button,summary,input,select,textarea,label')) return;
955
+ // Selecting text to copy it is reading, not commenting.
956
+ var picked = window.getSelection && window.getSelection();
957
+ if (picked && !picked.isCollapsed) return;
958
+ var block = t.closest('[data-mp]');
959
+ if (!block) return;
960
+ parent.postMessage({
961
+ __mockpit: true,
962
+ type: 'paragraph-tap',
963
+ version: version,
964
+ i: Number(block.getAttribute('data-mp')),
965
+ }, '*');
966
+ });
967
+ function tint(cls, list) {
968
+ var on = document.querySelectorAll('.' + cls);
969
+ for (var i = 0; i < on.length; i++) on[i].classList.remove(cls);
970
+ for (var k = 0; k < list.length && k < MAX_BLOCKS; k++) {
971
+ var el = document.querySelector('[data-mp="' + Number(list[k]) + '"]');
972
+ if (el) el.classList.add(cls);
973
+ }
974
+ }
975
+ window.addEventListener('message', function (e) {
976
+ if (e.source !== parent) return;
977
+ var d = e.data;
978
+ if (!d || d.__mockpit !== true || d.type !== 'paragraph-marks') return;
979
+ tint('mockpit-p-mark', Array.isArray(d.marked) ? d.marked : []);
980
+ tint('mockpit-p-on', d.on === null || d.on === undefined ? [] : [d.on]);
981
+ });
982
+ })();
983
+ `;
690
984
  // Knobs: the `?k=` values are already on <html> (see knobPreamble); this binds
691
985
  // text, announces them, and applies the host's live `knobs` commands.
692
986
  export const KNOBS_JS = `
@@ -840,55 +1134,36 @@ const TAILWIND_CDN = "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4";
840
1134
  // declares them per set and draws in currentColor.
841
1135
  const ICON_CSS = `.icon{width:1em;height:1em;flex:none;vertical-align:-0.125em}`;
842
1136
  // ---------------------------------------------------------------------------
843
- // Content-hashed static assets for surface documents
1137
+ // Mockpit's own scripts and stylesheets, inlined in every surface document
844
1138
  // ---------------------------------------------------------------------------
845
1139
  //
846
- // The bridge script and the theme-independent stylesheets used to be inlined
847
- // into EVERY surface document — ~11 KB of identical bytes per surface, paid
848
- // again for every post, every version, every theme and every mode. They are
849
- // served instead from `/asset/<name>.<hash>.<ext>`, so the browser fetches each
850
- // one once and reuses it across the whole workspace.
1140
+ // These were once served from content-hashed URLs so a browser would fetch each
1141
+ // file once. It doesn't: a frame's subresources are partitioned by its opaque
1142
+ // origin, so in Chrome nothing a frame fetches is cached or shared. Measured with
1143
+ // 8 frames, every file was fetched 8 times on first load and on every reload,
1144
+ // while the document itself came from disk cache. Bytes in the document cost
1145
+ // nothing past that document's one fetch, so that's where they live.
851
1146
  //
852
- // The hash is derived from the content at module load (FNV-1a — no crypto, no
853
- // async, runtime-agnostic), so an upgrade that changes a byte changes the URL:
854
- // a surface document cached as immutable for a year can never pair with stale
855
- // asset bytes.
856
- //
857
- // Only content that does NOT depend on the theme or the resolved mode lives
858
- // here. Theme tokens are per (theme, mode) and stay inline in the document,
859
- // where they are already covered by its cache key.
860
- const fnv1a = (input) => {
861
- let h = 0x811c9dc5;
862
- for (let i = 0; i < input.length; i++) {
863
- h ^= input.charCodeAt(i);
864
- h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
865
- }
866
- return h.toString(36);
867
- };
868
- // Every path is registered at module load, so a document served from a
869
- // long-lived cache after a restart always finds the asset it references.
870
- export const STATIC_ASSET_PREFIX = "/asset/";
871
- const STATIC_ASSETS = new Map();
872
- function registerAsset(name, ext, body) {
873
- const path = `${STATIC_ASSET_PREFIX}${name}.${fnv1a(body)}.${ext}`;
874
- STATIC_ASSETS.set(path, {
875
- path,
876
- contentType: ext === "js" ? "text/javascript; charset=utf-8" : "text/css; charset=utf-8",
877
- body,
878
- });
879
- return path;
880
- }
881
- export const staticAsset = (path) => STATIC_ASSETS.get(path) ?? null;
882
- const BRIDGE_PATH = registerAsset("bridge", "js", BRIDGE_JS);
883
- const HIT_TEST_PATH = registerAsset("hit-test", "js", HIT_TEST_JS);
884
- const PARTS_PATH = registerAsset("parts", "js", PARTS_JS);
885
- const KNOBS_PATH = registerAsset("knobs", "js", KNOBS_JS);
1147
+ // Comments and indentation in these scripts are for this file's reader; every
1148
+ // frame would pay for them, so they're dropped once at load. Safe because no
1149
+ // script here holds a multi-line string.
1150
+ const compactJs = (js) => js
1151
+ .split("\n")
1152
+ .map((line) => line.trim())
1153
+ .filter((line) => line && !line.startsWith("//"))
1154
+ .join("\n");
1155
+ const inlineScript = (js) => `<script>${compactJs(js)}</script>`;
1156
+ const inlineStyle = (css) => `<style>${css}</style>`;
1157
+ const FREEZE_TAG = inlineScript(FREEZE_JS);
1158
+ const BRIDGE_TAG = inlineScript(BRIDGE_JS);
1159
+ const HIT_TEST_TAG = inlineScript(HIT_TEST_JS);
1160
+ const PARTS_TAG = inlineScript(PARTS_JS);
1161
+ const KNOBS_TAG = inlineScript(KNOBS_JS);
1162
+ const PARAGRAPHS_TAG = inlineScript(PARAGRAPHS_JS);
886
1163
  // The base html-surface stylesheet: static design tokens + the surface kit.
887
- const BASE_CSS_PATH = registerAsset("base", "css", `${TOKENS_CSS}${KIT_CSS}${PART_HIGHLIGHT_CSS}${ICON_CSS}`);
888
- const KIT_CORE_PATH = registerAsset("kit-core", "css", CORE_CSS);
889
- const KIT_PATHS = new Map(KITS.map((k) => [k.id, registerAsset(`kit-${k.id}`, "css", k.css)]));
890
- const scriptTag = (origin, path) => `<script src="${origin}${path}"></script>`;
891
- const styleTag = (origin, path) => `<link rel="stylesheet" href="${origin}${path}">`;
1164
+ const BASE_CSS_TAG = inlineStyle(`${TOKENS_CSS}${KIT_CSS}${PART_HIGHLIGHT_CSS}${ICON_CSS}`);
1165
+ const KIT_CORE_TAG = inlineStyle(CORE_CSS);
1166
+ const versionAttr = (version) => version === undefined ? "" : ` data-mockpit-version="${version}"`;
892
1167
  // The same resolution kitAssets does (known ids, first occurrence wins), but
893
1168
  // yielding one stylesheet per kit instead of one concatenated string. Project
894
1169
  // kit URLs are re-checked here as well as on write: a stored setting is the
@@ -904,7 +1179,7 @@ function resolveKits(ids, projectKits = []) {
904
1179
  const kit = KITS.find((k) => k.id === id);
905
1180
  if (kit) {
906
1181
  seen.add(id);
907
- chosen.push({ id, cssPath: KIT_PATHS.get(id), href: kit.href, script: kit.script });
1182
+ chosen.push({ id, css: kit.css, href: kit.href, script: kit.script });
908
1183
  continue;
909
1184
  }
910
1185
  const own = projectKits.find((k) => k.id === id);
@@ -919,19 +1194,19 @@ function resolveKits(ids, projectKits = []) {
919
1194
  }
920
1195
  return chosen;
921
1196
  }
922
- // Everything a project's DesignSettings contributes to one html-surface doc.
1197
+ // Everything a project's DesignSystem contributes to one html-surface doc.
923
1198
  // Kept in one place so the ordering rule is visible: the project's own tokens
924
1199
  // land AFTER mockpit's, because a repo that declares `--radius` or a brand
925
1200
  // color should win inside its own project's surfaces.
926
- function designAssets(design, mode) {
927
- if (!design)
1201
+ function designAssets(designSystem, mode) {
1202
+ if (!designSystem)
928
1203
  return { css: "", kits: [], projectKits: [], headScripts: "", dark: false };
929
- const tailwindCss = design.kit === "tailwind" ? (design.tailwindCss?.trim() ?? "") : "";
930
- const raw = tailwindCss ? "" : (design.cssVars?.trim() ?? "");
1204
+ const tailwindCss = designSystem.kit === "tailwind" ? (designSystem.tailwindCss?.trim() ?? "") : "";
1205
+ const raw = tailwindCss ? "" : (designSystem.cssVars?.trim() ?? "");
931
1206
  // `cssVars` is stored as the repo's raw block; accept either the full
932
1207
  // `:root{…}` text or a bare declaration list.
933
1208
  const vars = raw ? (raw.includes("{") ? raw : `:root{${raw}}`) : "";
934
- const kit = design.kit;
1209
+ const kit = designSystem.kit;
935
1210
  // The browser build reads its input from the style tags present when its
936
1211
  // script runs, so the stylesheet goes first. Its compiled output already
937
1212
  // carries the repo's `:root` block, hence no `cssVars` beside it.
@@ -941,7 +1216,7 @@ function designAssets(design, mode) {
941
1216
  return {
942
1217
  css: vars,
943
1218
  kits: kit && kit !== "tailwind" && kit !== "none" ? [kit] : [],
944
- projectKits: Array.isArray(design.projectKits) ? design.projectKits : [],
1219
+ projectKits: Array.isArray(designSystem.projectKits) ? designSystem.projectKits : [],
945
1220
  headScripts: tailwind,
946
1221
  // Repos theme dark through a `.dark` ancestor (shadcn's `.dark {…}` block,
947
1222
  // `@custom-variant dark (&:is(.dark *))`), so the frame wears the class.
@@ -963,9 +1238,7 @@ export const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").
963
1238
  function buildRichCsp(origin) {
964
1239
  return [
965
1240
  `default-src 'none'`,
966
- // Same fixed same-origin asset directory as buildCsp — only the bridge
967
- // script lives there. Still no connect-src.
968
- `script-src 'unsafe-inline' ${origin}${STATIC_ASSET_PREFIX}`,
1241
+ `script-src 'unsafe-inline'`,
969
1242
  `style-src 'unsafe-inline'`,
970
1243
  `img-src https: data: blob: ${origin}`,
971
1244
  `font-src data:`,
@@ -990,7 +1263,7 @@ function buildRichCsp(origin) {
990
1263
  export function renderSandboxedPart(doc) {
991
1264
  const theme = typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
992
1265
  return `<!doctype html>
993
- <html lang="en">
1266
+ <html lang="en"${versionAttr(doc.version)}>
994
1267
  <head>
995
1268
  <meta charset="utf-8">
996
1269
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1004,7 +1277,8 @@ export function renderSandboxedPart(doc) {
1004
1277
  </head>
1005
1278
  <body>
1006
1279
  ${doc.body}
1007
- ${scriptTag(doc.origin, BRIDGE_PATH)}
1280
+ ${BRIDGE_TAG}
1281
+ ${PARAGRAPHS_TAG}
1008
1282
  </body>
1009
1283
  </html>`;
1010
1284
  }
@@ -1161,7 +1435,7 @@ try {
1161
1435
  el.appendChild(pre);
1162
1436
  }`;
1163
1437
  return `<!doctype html>
1164
- <html lang="en">
1438
+ <html lang="en"${versionAttr(doc.version)}>
1165
1439
  <head>
1166
1440
  <meta charset="utf-8">
1167
1441
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1172,35 +1446,35 @@ try {
1172
1446
  <body>
1173
1447
  <div id="m"></div>
1174
1448
  <script type="module">${loader}</script>
1175
- ${scriptTag(doc.origin, BRIDGE_PATH)}
1449
+ ${BRIDGE_TAG}
1450
+ ${PARAGRAPHS_TAG}
1176
1451
  </body>
1177
1452
  </html>`;
1178
1453
  }
1179
1454
  export function renderHtmlPage(doc) {
1180
1455
  const theme = typeof doc.theme === "string" || doc.theme == null ? themeById(doc.theme) : doc.theme;
1181
- const design = designAssets(doc.design, doc.mode);
1456
+ const designSystem = designAssets(doc.designSystem, doc.mode);
1182
1457
  const preamble = knobPreamble(doc.version, doc.knobs);
1183
- const htmlTag = design.dark
1458
+ const htmlTag = designSystem.dark
1184
1459
  ? preamble.htmlTag.replace("<html ", '<html class="dark" ')
1185
1460
  : preamble.htmlTag;
1186
1461
  // The project kit is appended, so with both present its components win over
1187
1462
  // a surface-requested kit's same-named classes.
1188
- const kitIds = [...(doc.kits ?? []), ...design.kits];
1189
- const kits = resolveKits(kitIds, design.projectKits);
1463
+ const kitIds = [...(doc.kits ?? []), ...designSystem.kits];
1464
+ const kits = resolveKits(kitIds, designSystem.projectKits);
1190
1465
  const kit = kitAssets(kitIds);
1191
- // Document order still decides the cascade, so the externalized stylesheets
1192
- // sit exactly where their inlined text used to: theme tokens, base, kit
1193
- // accents, kit(s), then the project's own vars (which must win last).
1466
+ // Document order decides the cascade: theme tokens, base, kit accents, kit(s),
1467
+ // then the project's own vars (which must win last).
1194
1468
  const styles = [
1195
1469
  `<style>${tokenThemeCss(theme, doc.mode)}</style>`,
1196
- styleTag(doc.origin, BASE_CSS_PATH),
1470
+ BASE_CSS_TAG,
1197
1471
  `<style>${kitAccentCss(doc.mode)}</style>`,
1198
- ...(kits.some((k) => !k.href) ? [styleTag(doc.origin, KIT_CORE_PATH)] : []),
1472
+ ...(kits.some((k) => !k.href) ? [KIT_CORE_TAG] : []),
1199
1473
  ...kits.flatMap((k) => [
1200
1474
  ...(k.href ? [`<link rel="stylesheet" href="${escapeHtml(k.href)}">`] : []),
1201
- ...(k.cssPath ? [styleTag(doc.origin, k.cssPath)] : []),
1475
+ ...(k.css ? [inlineStyle(k.css)] : []),
1202
1476
  ]),
1203
- `<style>${design.css}${colorSchemeCss(doc.mode)}</style>`,
1477
+ `<style>${designSystem.css}${colorSchemeCss(doc.mode)}</style>`,
1204
1478
  ].join("\n");
1205
1479
  // Slots (fills.ts): only the chosen fill of each shows, keyed on the knob
1206
1480
  // attribute; with no value yet, the default fill.
@@ -1212,19 +1486,20 @@ ${htmlTag}
1212
1486
  <meta name="viewport" content="width=device-width, initial-scale=1">
1213
1487
  <meta http-equiv="Content-Security-Policy" content="${buildCsp(doc.origin)}">
1214
1488
  <title>${escapeHtml(doc.title)}</title>
1489
+ ${FREEZE_TAG}
1215
1490
  ${preamble.headScript}
1216
- ${scriptTag(doc.origin, KNOBS_PATH)}
1491
+ ${KNOBS_TAG}
1217
1492
  ${styles}
1218
1493
  ${fillCss ? `<style>${fillCss}</style>` : ""}
1219
- ${design.headScripts}
1494
+ ${designSystem.headScripts}
1220
1495
  ${kits.flatMap((k) => (k.script ? [`<script src="${escapeHtml(k.script)}" defer></script>`] : [])).join("\n")}
1221
1496
  </head>
1222
1497
  <body>
1223
1498
  ${SVG_DEFS}
1224
1499
  ${expandIcons(doc.html, doc.resolveIcon ?? (() => null)).html}
1225
- ${scriptTag(doc.origin, BRIDGE_PATH)}
1226
- ${scriptTag(doc.origin, HIT_TEST_PATH)}
1227
- ${scriptTag(doc.origin, PARTS_PATH)}
1500
+ ${BRIDGE_TAG}
1501
+ ${HIT_TEST_TAG}
1502
+ ${PARTS_TAG}
1228
1503
  ${kit.js ? `<script>${kit.js}</script>` : ""}
1229
1504
  </body>
1230
1505
  </html>`;