mockpit 2.0.0 → 3.1.0

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