mockpit 0.0.0-stage → 1.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 (86) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +239 -2
  3. package/bin/demoData.js +132 -0
  4. package/bin/initDesign.js +846 -0
  5. package/bin/mockpit.js +1835 -0
  6. package/bin/serveUrl.d.ts +1 -0
  7. package/bin/serveUrl.js +7 -0
  8. package/dist/mcp/server.d.ts +2 -0
  9. package/dist/mcp/server.js +252 -0
  10. package/dist/server/apiViews.d.ts +329 -0
  11. package/dist/server/apiViews.js +135 -0
  12. package/dist/server/app.d.ts +55 -0
  13. package/dist/server/app.js +3134 -0
  14. package/dist/server/base64.d.ts +1 -0
  15. package/dist/server/base64.js +16 -0
  16. package/dist/server/cdn.d.ts +6 -0
  17. package/dist/server/cdn.js +33 -0
  18. package/dist/server/codeRunner.d.ts +5 -0
  19. package/dist/server/codeRunner.js +89 -0
  20. package/dist/server/codeWorker.d.ts +1 -0
  21. package/dist/server/codeWorker.js +196 -0
  22. package/dist/server/designGuide.d.ts +16 -0
  23. package/dist/server/designGuide.js +311 -0
  24. package/dist/server/eventLog.d.ts +27 -0
  25. package/dist/server/eventLog.js +187 -0
  26. package/dist/server/events.d.ts +53 -0
  27. package/dist/server/events.js +14 -0
  28. package/dist/server/feedbackBatch.d.ts +43 -0
  29. package/dist/server/feedbackBatch.js +98 -0
  30. package/dist/server/icons.d.ts +50 -0
  31. package/dist/server/icons.js +225 -0
  32. package/dist/server/index.d.ts +1 -0
  33. package/dist/server/index.js +76 -0
  34. package/dist/server/kits.d.ts +36 -0
  35. package/dist/server/kits.js +279 -0
  36. package/dist/server/knobs.d.ts +15 -0
  37. package/dist/server/knobs.js +333 -0
  38. package/dist/server/logFile.d.ts +2 -0
  39. package/dist/server/logFile.js +27 -0
  40. package/dist/server/mcpHttp.d.ts +53 -0
  41. package/dist/server/mcpHttp.js +143 -0
  42. package/dist/server/mcpSpec.d.ts +191 -0
  43. package/dist/server/mcpSpec.js +402 -0
  44. package/dist/server/parts.d.ts +31 -0
  45. package/dist/server/parts.js +338 -0
  46. package/dist/server/postMarkdown.d.ts +10 -0
  47. package/dist/server/postMarkdown.js +271 -0
  48. package/dist/server/postSurfaces.d.ts +40 -0
  49. package/dist/server/postSurfaces.js +407 -0
  50. package/dist/server/public.d.ts +5 -0
  51. package/dist/server/public.js +5 -0
  52. package/dist/server/push.d.ts +45 -0
  53. package/dist/server/push.js +234 -0
  54. package/dist/server/richRender.d.ts +15 -0
  55. package/dist/server/richRender.js +392 -0
  56. package/dist/server/run.d.ts +82 -0
  57. package/dist/server/run.js +290 -0
  58. package/dist/server/runApi.d.ts +6 -0
  59. package/dist/server/runApi.js +82 -0
  60. package/dist/server/slots.d.ts +14 -0
  61. package/dist/server/slots.js +71 -0
  62. package/dist/server/sqlStore.d.ts +119 -0
  63. package/dist/server/sqlStore.js +954 -0
  64. package/dist/server/sqliteStorage.d.ts +2 -0
  65. package/dist/server/sqliteStorage.js +90 -0
  66. package/dist/server/surfacePage.d.ts +48 -0
  67. package/dist/server/surfacePage.js +1099 -0
  68. package/dist/server/themes.d.ts +48 -0
  69. package/dist/server/themes.js +305 -0
  70. package/dist/server/types.d.ts +599 -0
  71. package/dist/server/types.js +299 -0
  72. package/dist/workers/public.d.ts +1 -0
  73. package/dist/workers/public.js +3 -0
  74. package/extensions/mockpit.js +717 -0
  75. package/guide/AGENT_SETUP.md +23 -0
  76. package/guide/topics/asks.md +87 -0
  77. package/guide/topics/feedback.md +127 -0
  78. package/guide/topics/html.md +175 -0
  79. package/guide/topics/http.md +76 -0
  80. package/guide/topics/knobs.md +72 -0
  81. package/guide/topics/scripts.md +129 -0
  82. package/guide/topics/surfaces.md +100 -0
  83. package/package.json +165 -4
  84. package/skills/launch-video/SKILL.md +159 -0
  85. package/skills/mockpit/SKILL.md +40 -0
  86. package/viewer/dist/index.html +1922 -0
@@ -0,0 +1,279 @@
1
+ // Opt-in style/behavior bundles for html surfaces. An html surface may list kit ids
2
+ // in its `kits` field; renderHtmlPage concatenates each requested kit's CSS
3
+ // (and JS) into the sandboxed document AFTER the base KIT_CSS. A default html
4
+ // surface (no `kits`) is untouched — the richer vocabulary only ships when asked
5
+ // for, so kits never homogenize freeform html. Kits are a library you import
6
+ // per surface, not a frame every surface is locked into.
7
+ //
8
+ // Runtime-agnostic (no node imports): imported by surfacePage (server render),
9
+ // postSurfaces (id allowlist), and surfaced over HTTP/MCP for discovery. Every
10
+ // class resolves against the theme `--color-*` / `--font-*` / radius tokens, so
11
+ // kit output re-themes with the workspace like any other html surface.
12
+ import { checkCdnUrl } from "./cdn.js";
13
+ // Layout + text helpers shared by every kit — injected ONCE whenever any kit is
14
+ // requested, so kit-specific css below only declares its distinctive classes.
15
+ // Exported so surfacePage.ts can publish it as its own content-hashed
16
+ // stylesheet instead of inlining it into every surface document.
17
+ export const CORE_CSS = `
18
+ .stack{display:flex;flex-direction:column;gap:8px}.stack.sm{gap:4px}.stack.lg{gap:16px}
19
+ .row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.sm{gap:4px}
20
+ .between{display:flex;align-items:center;justify-content:space-between;gap:12px}
21
+ .grow{flex:1;min-width:0}
22
+ .title{font:500 15px/1.35 var(--font-sans);color:var(--color-text-primary)}
23
+ .dim{color:var(--color-text-secondary)}.faint{color:var(--color-text-tertiary)}
24
+ .mono{font-family:var(--font-mono)}.num{font-variant-numeric:tabular-nums}
25
+ .hr{height:1px;border:0;margin:2px 0;background:var(--color-border-secondary)}
26
+ .kbd{font:400 12px/1 var(--font-mono);padding:2px 6px;border:1px solid var(--color-border-secondary);border-bottom-width:2px;border-radius:6px;background:var(--color-background-secondary);color:var(--color-text-secondary)}
27
+ `;
28
+ // issues: cards, status badges/dots, mono ref chips, rollup bars, and a nesting
29
+ // rail (.tree → nest another .tree to indent). Composes an issue/PR/CI tree,
30
+ // a status board, or a PR summary from generic primitives.
31
+ const ISSUES_CSS = `
32
+ .card{background:var(--color-background-primary);border:1px solid var(--color-border-secondary);border-radius:var(--border-radius-lg);padding:14px 16px}
33
+ .card.soft{background:var(--color-background-secondary)}
34
+ .badge{display:inline-flex;align-items:center;gap:5px;font:500 12px/1.4 var(--font-sans);padding:2px 9px;border-radius:999px;background:var(--color-background-secondary);color:var(--color-text-secondary)}
35
+ .badge.ok{background:var(--color-background-success);color:var(--color-text-success)}
36
+ .badge.info{background:var(--color-background-info);color:var(--color-text-info)}
37
+ .badge.warn{background:var(--color-background-warning);color:var(--color-text-warning)}
38
+ .badge.danger{background:var(--color-background-danger);color:var(--color-text-danger)}
39
+ .chip{display:inline-flex;align-items:center;gap:4px;font:400 12px/1.4 var(--font-mono);padding:1px 7px;border-radius:var(--border-radius-md);border:1px solid var(--color-border-secondary);color:var(--color-text-secondary)}
40
+ .dot{width:8px;height:8px;border-radius:999px;background:var(--color-text-tertiary);flex:none}
41
+ .dot.ok{background:var(--color-text-success)}.dot.info{background:var(--color-text-info)}.dot.warn{background:var(--color-text-warning)}.dot.danger{background:var(--color-text-danger)}
42
+ .bar{height:6px;border-radius:999px;background:var(--color-background-secondary);overflow:hidden}
43
+ .bar>i{display:block;height:100%;border-radius:inherit;background:var(--color-text-success)}
44
+ .tree{display:flex;flex-direction:column;gap:7px;margin:0;padding:0;list-style:none}
45
+ .tree .tree{margin-top:7px;margin-left:9px;padding-left:14px;border-left:1px solid var(--color-border-secondary)}
46
+ `;
47
+ // slides: a stepped deck. Author `.deck` with `.slide` children; the JS shows
48
+ // one at a time and injects prev/dots/counter/next controls. Arrow keys and
49
+ // PageUp/Down navigate (plain keys only — the host owns the meta/alt combos).
50
+ //
51
+ // The slides are GRID-STACKED (every `.slide` in the same `1/1` cell), not
52
+ // swapped with display:none or overlaid with position:absolute. That keeps them
53
+ // in normal flow — so the deck (and the document) sizes to the TALLEST slide and
54
+ // the height the surface reports stays stable — while still letting them overlap
55
+ // so inactive slides can cross-fade under the active one. An absolute overlay
56
+ // would grow scrollHeight without growing the measured box, and the frame's
57
+ // ResizeObserver (which watches the box, not scrollHeight) would go blind to it
58
+ // and clip the deck; grid-stacking avoids that trap. Inactive slides stay laid
59
+ // out (so they keep sizing the track) but are visibility:hidden — out of the tab
60
+ // order and the a11y tree — until they become `.on`.
61
+ const SLIDES_CSS = `
62
+ .deck{display:grid;min-height:140px}
63
+ .deck>.slide{grid-area:1/1;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .3s ease,visibility 0s linear .3s}
64
+ .deck>.slide.on{opacity:1;visibility:visible;pointer-events:auto;transition:opacity .3s ease}
65
+ @media (prefers-reduced-motion:reduce){.deck>.slide{transition:none}}
66
+ .deck>.slide h2{font:500 22px/1.3 var(--font-sans);margin:0 0 14px}
67
+ .deck-ctl{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:18px;padding-top:14px;border-top:1px solid var(--color-border-secondary)}
68
+ .deck-dots{display:inline-flex;gap:7px}
69
+ .deck-dots i{width:7px;height:7px;border-radius:999px;background:var(--color-border-primary);cursor:pointer}
70
+ .deck-dots i.on{background:var(--color-text-info)}
71
+ .deck-num{font:400 13px/1 var(--font-mono);color:var(--color-text-tertiary);min-width:46px;text-align:center}
72
+ `;
73
+ const SLIDES_JS = `
74
+ (function(){
75
+ var deck=document.querySelector('.deck');if(!deck)return;
76
+ var slides=[].slice.call(deck.querySelectorAll(':scope > .slide'));if(!slides.length)return;
77
+ var i=0;
78
+ var ctl=document.createElement('div');ctl.className='deck-ctl';
79
+ var prev=document.createElement('button');prev.type='button';prev.setAttribute('aria-label','Previous slide');prev.textContent='‹';
80
+ var next=document.createElement('button');next.type='button';next.setAttribute('aria-label','Next slide');next.textContent='›';
81
+ var dots=document.createElement('span');dots.className='deck-dots';
82
+ var num=document.createElement('span');num.className='deck-num';
83
+ slides.forEach(function(_,k){var d=document.createElement('i');d.setAttribute('role','button');d.addEventListener('click',function(){go(k);});dots.appendChild(d);});
84
+ ctl.appendChild(prev);ctl.appendChild(dots);ctl.appendChild(num);ctl.appendChild(next);
85
+ deck.appendChild(ctl);
86
+ function go(n){i=(n+slides.length)%slides.length;
87
+ slides.forEach(function(s,k){s.classList.toggle('on',k===i);});
88
+ [].forEach.call(dots.children,function(d,k){d.classList.toggle('on',k===i);});
89
+ num.textContent=(i+1)+' / '+slides.length;}
90
+ prev.addEventListener('click',function(){go(i-1);});
91
+ next.addEventListener('click',function(){go(i+1);});
92
+ document.addEventListener('keydown',function(e){
93
+ if(e.metaKey||e.altKey||e.ctrlKey)return;
94
+ if(e.key==='ArrowRight'||e.key==='PageDown'){e.preventDefault();go(i+1);}
95
+ else if(e.key==='ArrowLeft'||e.key==='PageUp'){e.preventDefault();go(i-1);}});
96
+ go(0);
97
+ })();
98
+ `;
99
+ // builtin: the fallback component vocabulary `mockpit init` injects when a
100
+ // project has no Tailwind/shadcn of its own. Class names mirror shadcn so an
101
+ // agent that knows one writes the other without a lookup, but every rule is
102
+ // CSS-only — no JS, no CDN — and every color comes from the `--color-*` tokens
103
+ // the wrapper already emits, so it re-themes with the workspace and with an
104
+ // imported project palette alike. Radius prefers the project's own `--radius`
105
+ // (imported verbatim by init) and falls back to the mockpit token.
106
+ //
107
+ // It deliberately redeclares `.card`/`.badge` from the `issues` kit: surfaces
108
+ // ask for one or the other, and renderHtmlPage appends builtin last so a
109
+ // project's component look wins if both are somehow present.
110
+ const BUILTIN_CSS = `
111
+ :root{--r:var(--radius,var(--border-radius-md))}
112
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;font:500 14px/1 var(--font-sans);padding:9px 16px;border-radius:var(--r);border:1px solid var(--color-border-secondary);background:var(--color-background-primary);color:var(--color-text-primary);cursor:pointer;text-decoration:none;white-space:nowrap}
113
+ .btn:hover{background:var(--color-background-secondary)}
114
+ .btn:focus-visible{outline:2px solid var(--color-border-info);outline-offset:2px}
115
+ .btn[disabled],.btn.disabled{opacity:.5;pointer-events:none}
116
+ .btn-primary{background:var(--color-text-info);border-color:var(--color-text-info);color:var(--color-background-primary)}
117
+ .btn-primary:hover{opacity:.9;background:var(--color-text-info)}
118
+ .btn-secondary{background:var(--color-background-secondary);border-color:transparent}
119
+ .btn-ghost{background:none;border-color:transparent}
120
+ .btn-ghost:hover{background:var(--color-background-secondary)}
121
+ .btn-destructive{background:var(--color-text-danger);border-color:var(--color-text-danger);color:var(--color-background-primary)}
122
+ .btn-sm{font-size:13px;padding:6px 11px}
123
+ .btn-lg{font-size:15px;padding:12px 22px}
124
+ .card{background:var(--color-background-primary);border:1px solid var(--color-border-secondary);border-radius:calc(var(--r) + 4px);padding:20px;display:flex;flex-direction:column;gap:12px}
125
+ .card-header{display:flex;flex-direction:column;gap:4px}
126
+ .card-title{font:500 16px/1.3 var(--font-sans);margin:0}
127
+ .card-desc{font-size:13px;color:var(--color-text-secondary);margin:0}
128
+ .card-content{display:flex;flex-direction:column;gap:10px}
129
+ .card-footer{display:flex;align-items:center;gap:8px;padding-top:4px}
130
+ .label{font:500 13px/1.3 var(--font-sans);color:var(--color-text-primary)}
131
+ .input,.textarea,.select{width:100%;font:14px/1.4 var(--font-sans);color:var(--color-text-primary);background:var(--color-background-primary);border:1px solid var(--color-border-secondary);border-radius:var(--r);padding:8px 11px;outline:none}
132
+ .input:focus,.textarea:focus,.select:focus{border-color:var(--color-border-info);box-shadow:0 0 0 1px var(--color-border-info)}
133
+ .input::placeholder,.textarea::placeholder{color:var(--color-text-tertiary)}
134
+ .textarea{min-height:76px;resize:vertical}
135
+ .field{display:flex;flex-direction:column;gap:6px}
136
+ .badge{display:inline-flex;align-items:center;gap:5px;font:500 12px/1.4 var(--font-sans);padding:2px 9px;border-radius:999px;background:var(--color-text-info);color:var(--color-background-primary);border:1px solid transparent}
137
+ .badge-secondary{background:var(--color-background-secondary);color:var(--color-text-secondary)}
138
+ .badge-outline{background:none;border-color:var(--color-border-secondary);color:var(--color-text-secondary)}
139
+ .badge-destructive{background:var(--color-text-danger);color:var(--color-background-primary)}
140
+ .tabs{display:flex;flex-direction:column;gap:14px}
141
+ .tabs-list{display:inline-flex;gap:2px;padding:3px;border-radius:var(--r);background:var(--color-background-secondary);align-self:flex-start}
142
+ .tab{font:500 13px/1 var(--font-sans);padding:7px 13px;border:0;border-radius:calc(var(--r) - 2px);background:none;color:var(--color-text-secondary);cursor:pointer}
143
+ .tab:hover{color:var(--color-text-primary)}
144
+ .tab.on,.tab[aria-selected=true]{background:var(--color-background-primary);color:var(--color-text-primary)}
145
+ .tab-panel{display:none}.tab-panel.on{display:block}
146
+ .dialog{position:relative;margin:0 auto;max-width:440px;background:var(--color-background-primary);border:1px solid var(--color-border-secondary);border-radius:calc(var(--r) + 4px);padding:22px;display:flex;flex-direction:column;gap:14px;box-shadow:none}
147
+ .dialog-scrim{padding:26px;border-radius:calc(var(--r) + 6px);background:var(--color-background-tertiary)}
148
+ .dialog-header{display:flex;flex-direction:column;gap:5px}
149
+ .dialog-title{font:500 16px/1.3 var(--font-sans);margin:0}
150
+ .dialog-desc{font-size:13px;color:var(--color-text-secondary);margin:0}
151
+ .dialog-footer{display:flex;justify-content:flex-end;gap:8px}
152
+ .table{width:100%;border-collapse:collapse;font-size:14px}
153
+ .table th{text-align:left;font-weight:500;color:var(--color-text-secondary);padding:8px 12px;border-bottom:1px solid var(--color-border-secondary)}
154
+ .table td{padding:9px 12px;border-bottom:1px solid var(--color-border-tertiary)}
155
+ .table tbody tr:last-child td{border-bottom:0}
156
+ .table tbody tr:hover{background:var(--color-background-secondary)}
157
+ .table .num{text-align:right}
158
+ .sep{height:1px;border:0;margin:0;background:var(--color-border-secondary)}
159
+ .muted-fg{color:var(--color-text-secondary)}
160
+ `;
161
+ // Basecoat reads shadcn's variable names. A project that imported its own
162
+ // shadcn `:root` block overrides these (its vars are injected last); a project
163
+ // with none still gets a themed Basecoat in both modes, because each name
164
+ // resolves to a mockpit token that already flips with the mode.
165
+ const BASECOAT_THEME_CSS = `
166
+ :root{--radius:var(--border-radius-md);--background:var(--color-background-primary);--foreground:var(--color-text-primary);--card:var(--color-background-primary);--card-foreground:var(--color-text-primary);--popover:var(--color-background-primary);--popover-foreground:var(--color-text-primary);--primary:var(--color-text-primary);--primary-foreground:var(--color-background-primary);--secondary:var(--color-background-secondary);--secondary-foreground:var(--color-text-primary);--muted:var(--color-background-secondary);--muted-foreground:var(--color-text-secondary);--accent:var(--color-background-secondary);--accent-foreground:var(--color-text-primary);--destructive:var(--color-text-danger);--border:var(--color-border-secondary);--input:var(--color-border-secondary);--ring:var(--color-border-info)}
167
+ `;
168
+ // Pinned to a major so fixes arrive without a server release. 1.x moved
169
+ // variants from `.btn-primary` classes to `data-variant`.
170
+ const BASECOAT_CDN = "https://cdn.jsdelivr.net/npm/basecoat-css@1/dist";
171
+ export const KITS = [
172
+ {
173
+ id: "builtin",
174
+ label: "Builtin",
175
+ summary: "shadcn-shaped components (CSS only) for projects with no design system",
176
+ classes: "btn · card · input · label · badge · tabs · dialog · table",
177
+ css: BUILTIN_CSS,
178
+ },
179
+ {
180
+ id: "issues",
181
+ label: "Issues",
182
+ summary: "issue / PR / CI status — trees, badges, chips, rollup bars",
183
+ classes: "card · tree · badge · chip · dot · bar",
184
+ css: ISSUES_CSS,
185
+ },
186
+ {
187
+ id: "slides",
188
+ label: "Slides",
189
+ summary: "a stepped, cross-fading deck with prev/next controls and a counter",
190
+ classes: "deck · slide (+ injected controls)",
191
+ css: SLIDES_CSS,
192
+ js: SLIDES_JS,
193
+ },
194
+ {
195
+ id: "basecoat",
196
+ label: "Basecoat",
197
+ summary: "shadcn's look as plain CSS classes (Basecoat UI from jsDelivr); themes from shadcn vars",
198
+ classes: 'btn card input label badge alert table tabs dialog kbd; data-variant="secondary|outline|ghost|destructive"',
199
+ css: BASECOAT_THEME_CSS,
200
+ href: `${BASECOAT_CDN}/basecoat.cdn.min.css`,
201
+ // Tabs (advertised above) switch only with Basecoat's script; the bundle
202
+ // also drives dropdown-menu, select and popover markup.
203
+ script: `${BASECOAT_CDN}/js/all.min.js`,
204
+ },
205
+ ];
206
+ const KIT_BY_ID = new Map(KITS.map((k) => [k.id, k]));
207
+ export const isKnownKit = (id) => typeof id === "string" && KIT_BY_ID.has(id);
208
+ export const KIT_IDS = KITS.map((k) => k.id);
209
+ // Compact descriptor for discovery (no CSS/JS payload), followed by the
210
+ // project's own kits when given.
211
+ export const kitSummaries = (projectKits = []) => [
212
+ ...KITS.map((k) => ({
213
+ id: k.id,
214
+ source: "bundled",
215
+ label: k.label,
216
+ summary: k.summary,
217
+ classes: k.classes,
218
+ ...(k.href ? { href: k.href } : {}),
219
+ })),
220
+ ...projectKits.map((k) => ({ ...k, source: "project" })),
221
+ ];
222
+ // Ids a project kit may not take: they already mean something in design.kit.
223
+ export const RESERVED_KIT_IDS = ["tailwind", "none", ...KIT_IDS];
224
+ export const PROJECT_KIT_DOC_MAX = 1200;
225
+ const PROJECT_KIT_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
226
+ // One project kit as it arrives over the API. Its URLs become tags in the
227
+ // sandboxed document, so they must pass the same allowlist as the CSP.
228
+ export function checkProjectKit(raw) {
229
+ if (!raw || typeof raw !== "object")
230
+ return "a kit must be an object {id, href, doc}";
231
+ const k = raw;
232
+ if (typeof k.id !== "string" || !PROJECT_KIT_ID.test(k.id))
233
+ return "kit id must be lowercase letters, digits and dashes, at most 32";
234
+ if (RESERVED_KIT_IDS.includes(k.id))
235
+ return `kit id "${k.id}" is taken by a bundled kit`;
236
+ const href = checkCdnUrl(k.href, "kit url");
237
+ if ("error" in href)
238
+ return href.error;
239
+ let script;
240
+ if (k.script !== undefined && k.script !== null && k.script !== "") {
241
+ const checked = checkCdnUrl(k.script, "kit script");
242
+ if ("error" in checked)
243
+ return checked.error;
244
+ script = checked.url;
245
+ }
246
+ const doc = typeof k.doc === "string" ? k.doc.trim() : "";
247
+ if (!doc)
248
+ return "a kit needs a doc: the class cheat sheet the brief prints";
249
+ if (doc.length > PROJECT_KIT_DOC_MAX)
250
+ return `kit doc is ${doc.length} chars; keep it under ${PROJECT_KIT_DOC_MAX}`;
251
+ return { id: k.id, href: href.url, ...(script ? { script } : {}), doc };
252
+ }
253
+ // Resolve a list of kit ids to the CSS/JS to inject. Unknown ids are ignored;
254
+ // duplicates collapse; CORE ships once when any known kit is present.
255
+ export function kitAssets(ids) {
256
+ if (!ids || ids.length === 0)
257
+ return { css: "", js: "" };
258
+ const seen = new Set();
259
+ const chosen = [];
260
+ for (const id of ids) {
261
+ const kit = KIT_BY_ID.get(id);
262
+ if (kit && !seen.has(id)) {
263
+ seen.add(id);
264
+ chosen.push(kit);
265
+ }
266
+ }
267
+ if (chosen.length === 0)
268
+ return { css: "", js: "" };
269
+ // A reference kit brings its own vocabulary; CORE's `.kbd` would override
270
+ // the library's.
271
+ let css = chosen.some((k) => !k.href) ? CORE_CSS : "";
272
+ let js = "";
273
+ for (const kit of chosen) {
274
+ css += kit.css;
275
+ if (kit.js)
276
+ js += kit.js;
277
+ }
278
+ return { css, js };
279
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExplicitKnob, KnobConfig, Knobs, KnobValue, Mock, Post } from "./types.ts";
2
+ export type Checked<T> = {
3
+ ok: true;
4
+ value: T;
5
+ } | {
6
+ ok: false;
7
+ error: string;
8
+ };
9
+ export declare function explicitKnob(config: KnobConfig): ExplicitKnob;
10
+ export declare function checkKnobConfig(path: string, raw: unknown): Checked<KnobConfig>;
11
+ export declare function checkKnobs(raw: unknown): Checked<Knobs>;
12
+ export declare function checkKnobValue(path: string, config: KnobConfig, value: unknown): Checked<KnobValue>;
13
+ export declare function knobFor(path: string, mock: Pick<Mock, "knobs">, posts: Pick<Post, "knobs">[]): KnobConfig | undefined;
14
+ export declare function checkKnobValues(raw: unknown, mock: Pick<Mock, "knobs">, posts: Pick<Post, "knobs">[], what: string): Checked<Record<string, KnobValue>>;
15
+ export declare function discreteChoices(config: KnobConfig): number | null;
@@ -0,0 +1,333 @@
1
+ // Knob schema and value validation (plan Q3: values are checked against the
2
+ // declared schema before they are stored or used). Runtime-agnostic. The config
3
+ // shape mirrors tunekit's usePane config; see the knob types in types.ts.
4
+ const MAX_KNOBS = 100;
5
+ const MAX_OPTIONS = 100;
6
+ const MAX_STRING = 500;
7
+ const MAX_TEXT_VALUE = 2000;
8
+ // A path is "size" (global) or "<part>.<name>"; segments stay CSS/attribute-safe
9
+ // because the bridge turns them into `--k-<path>` vars and `data-k-<path>` attrs.
10
+ const PATH_RE = /^[A-Za-z_][\w-]{0,63}(\.[A-Za-z_][\w-]{0,63}){0,3}$/;
11
+ // Color values end up inside CSS custom properties, so only the characters a
12
+ // CSS color or gradient needs are allowed.
13
+ const COLOR_RE = /^[#\w\s(),.%/+-]{1,300}$/;
14
+ // The character class above still admits `url(//host/x)` and `image(...)`, which
15
+ // would turn a colour var into a network fetch inside the sandbox; a colour never
16
+ // needs either function.
17
+ const COLOR_FETCH_RE = /\b(url|image|image-set|src)\s*\(/i;
18
+ const isCssColor = (v) => COLOR_RE.test(v) && !COLOR_FETCH_RE.test(v);
19
+ const COLOR_HINT = /^(#|rgba?\(|hsla?\(|hwb\(|lab\(|lch\(|oklab\(|oklch\(|color\(|(linear|radial|conic)-gradient\()/i;
20
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
21
+ const isStr = (v, max = MAX_STRING) => typeof v === "string" && v.length <= max;
22
+ function checkAxis(raw, name) {
23
+ if (!Array.isArray(raw) || raw.length < 3 || raw.length > 4 || !raw.every(isNum)) {
24
+ return { ok: false, error: `${name} must be [default, min, max, step?] numbers` };
25
+ }
26
+ const [value, min, max, step] = raw;
27
+ if (min > max)
28
+ return { ok: false, error: `${name} min is above max` };
29
+ if (value < min || value > max)
30
+ return { ok: false, error: `${name} default is outside min..max` };
31
+ if (step !== undefined && step <= 0)
32
+ return { ok: false, error: `${name} step must be positive` };
33
+ return { ok: true, value: raw.slice() };
34
+ }
35
+ function checkOptions(raw, name) {
36
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_OPTIONS) {
37
+ return { ok: false, error: `${name} options must be a non-empty array` };
38
+ }
39
+ const out = [];
40
+ for (const o of raw) {
41
+ if (isStr(o))
42
+ out.push(o);
43
+ else if (o && typeof o === "object" && isStr(o.value) && isStr(o.label)) {
44
+ out.push({ value: o.value, label: o.label });
45
+ }
46
+ else
47
+ return { ok: false, error: `${name} options must be strings or {value, label}` };
48
+ }
49
+ return { ok: true, value: out };
50
+ }
51
+ const optionValues = (options) => (options ?? []).map((o) => (typeof o === "string" ? o : o.value));
52
+ // Expand tunekit's shorthands into an explicit control, the same way tunekit's
53
+ // config parser does (a string is a color when it looks like one, else text).
54
+ export function explicitKnob(config) {
55
+ if (Array.isArray(config)) {
56
+ const [value, min, max, step] = config;
57
+ return { type: "slider", value, min, max, ...(step === undefined ? {} : { step }) };
58
+ }
59
+ if (typeof config === "number") {
60
+ if (config >= 0 && config <= 1)
61
+ return { type: "slider", value: config, min: 0, max: 1 };
62
+ if (config >= 0)
63
+ return { type: "slider", value: config, min: 0, max: config * 3 || 10 };
64
+ return { type: "slider", value: config, min: config * 3, max: -config * 3 };
65
+ }
66
+ if (typeof config === "boolean")
67
+ return { type: "toggle", value: config };
68
+ if (typeof config === "string") {
69
+ return COLOR_HINT.test(config)
70
+ ? { type: "color", value: config }
71
+ : { type: "text", value: config };
72
+ }
73
+ return config;
74
+ }
75
+ // Validate one declared knob and return a clean copy (unknown keys dropped).
76
+ export function checkKnobConfig(path, raw) {
77
+ const name = `knob "${path}"`;
78
+ if (Array.isArray(raw))
79
+ return checkAxis(raw, name);
80
+ if (isNum(raw) || typeof raw === "boolean")
81
+ return { ok: true, value: raw };
82
+ if (typeof raw === "string") {
83
+ return raw.length <= MAX_STRING
84
+ ? { ok: true, value: raw }
85
+ : { ok: false, error: `${name} is too long` };
86
+ }
87
+ if (!raw || typeof raw !== "object")
88
+ return { ok: false, error: `${name} is not a knob` };
89
+ const k = raw;
90
+ switch (k.type) {
91
+ case "slider": {
92
+ const axis = checkAxis([k.value, k.min, k.max, ...(k.step === undefined ? [] : [k.step])], name);
93
+ if (!axis.ok)
94
+ return axis;
95
+ const [value, min, max, step] = axis.value;
96
+ return {
97
+ ok: true,
98
+ value: { type: "slider", value, min, max, ...(step === undefined ? {} : { step }) },
99
+ };
100
+ }
101
+ case "toggle":
102
+ return typeof k.value === "boolean"
103
+ ? { ok: true, value: { type: "toggle", value: k.value } }
104
+ : { ok: false, error: `${name} toggle needs a boolean value` };
105
+ case "select": {
106
+ const options = checkOptions(k.options, name);
107
+ if (!options.ok)
108
+ return options;
109
+ if (k.value !== undefined && !optionValues(options.value).includes(k.value)) {
110
+ return { ok: false, error: `${name} value is not one of its options` };
111
+ }
112
+ return {
113
+ ok: true,
114
+ value: {
115
+ type: "select",
116
+ options: options.value,
117
+ ...(k.value === undefined ? {} : { value: k.value }),
118
+ },
119
+ };
120
+ }
121
+ case "color": {
122
+ if (k.value !== undefined && !(typeof k.value === "string" && isCssColor(k.value))) {
123
+ return { ok: false, error: `${name} color value is not a CSS color` };
124
+ }
125
+ return {
126
+ ok: true,
127
+ value: {
128
+ type: "color",
129
+ ...(k.value === undefined ? {} : { value: k.value }),
130
+ ...(k.gradient === true ? { gradient: true } : {}),
131
+ ...(isStr(k.contrast) && isCssColor(k.contrast) ? { contrast: k.contrast } : {}),
132
+ },
133
+ };
134
+ }
135
+ case "text":
136
+ if (k.value !== undefined && !isStr(k.value, MAX_TEXT_VALUE)) {
137
+ return { ok: false, error: `${name} text value must be a string` };
138
+ }
139
+ return {
140
+ ok: true,
141
+ value: {
142
+ type: "text",
143
+ ...(k.value === undefined ? {} : { value: k.value }),
144
+ ...(isStr(k.placeholder) ? { placeholder: k.placeholder } : {}),
145
+ },
146
+ };
147
+ case "spring": {
148
+ const out = { type: "spring" };
149
+ for (const key of ["stiffness", "damping", "mass", "visualDuration", "bounce"]) {
150
+ if (k[key] === undefined)
151
+ continue;
152
+ if (!isNum(k[key]))
153
+ return { ok: false, error: `${name} ${key} must be a number` };
154
+ out[key] = k[key];
155
+ }
156
+ return { ok: true, value: out };
157
+ }
158
+ case "easing": {
159
+ if (!isNum(k.duration) || k.duration < 0) {
160
+ return { ok: false, error: `${name} easing needs a duration` };
161
+ }
162
+ if (!Array.isArray(k.ease) || k.ease.length !== 4 || !k.ease.every(isNum)) {
163
+ return { ok: false, error: `${name} ease must be four numbers` };
164
+ }
165
+ return {
166
+ ok: true,
167
+ value: {
168
+ type: "easing",
169
+ duration: k.duration,
170
+ ease: [...k.ease],
171
+ },
172
+ };
173
+ }
174
+ case "image": {
175
+ let options;
176
+ if (k.options !== undefined) {
177
+ const checked = checkOptions(k.options, name);
178
+ if (!checked.ok)
179
+ return checked;
180
+ options = checked.value;
181
+ }
182
+ if (k.value !== undefined && !isStr(k.value, MAX_TEXT_VALUE)) {
183
+ return { ok: false, error: `${name} image value must be a string` };
184
+ }
185
+ return {
186
+ ok: true,
187
+ value: {
188
+ type: "image",
189
+ ...(k.value === undefined ? {} : { value: k.value }),
190
+ ...(options ? { options } : {}),
191
+ },
192
+ };
193
+ }
194
+ case "pad": {
195
+ const out = { type: "pad" };
196
+ for (const axis of ["x", "y"]) {
197
+ if (k[axis] === undefined)
198
+ continue;
199
+ const checked = checkAxis(k[axis], `${name} ${axis}`);
200
+ if (!checked.ok)
201
+ return checked;
202
+ out[axis] = checked.value;
203
+ }
204
+ if (k.labels && typeof k.labels === "object") {
205
+ out.labels = {
206
+ ...(isStr(k.labels.x) ? { x: k.labels.x } : {}),
207
+ ...(isStr(k.labels.y) ? { y: k.labels.y } : {}),
208
+ };
209
+ }
210
+ return { ok: true, value: out };
211
+ }
212
+ default:
213
+ return { ok: false, error: `${name} has unknown type "${String(k.type)}"` };
214
+ }
215
+ }
216
+ export function checkKnobs(raw) {
217
+ if (raw === undefined || raw === null)
218
+ return { ok: true, value: {} };
219
+ if (typeof raw !== "object" || Array.isArray(raw)) {
220
+ return { ok: false, error: "knobs must be an object of path → knob" };
221
+ }
222
+ const entries = Object.entries(raw);
223
+ if (entries.length > MAX_KNOBS)
224
+ return { ok: false, error: `at most ${MAX_KNOBS} knobs` };
225
+ const out = {};
226
+ for (const [path, config] of entries) {
227
+ if (!PATH_RE.test(path)) {
228
+ return { ok: false, error: `knob path "${path}" must be "name" or "part.name"` };
229
+ }
230
+ const checked = checkKnobConfig(path, config);
231
+ if (!checked.ok)
232
+ return checked;
233
+ out[path] = checked.value;
234
+ }
235
+ return { ok: true, value: out };
236
+ }
237
+ const axisOf = (axis) => axis ? [axis[1], axis[2]] : [-1, 1];
238
+ // Whether `value` is something the declared knob could hold.
239
+ export function checkKnobValue(path, config, value) {
240
+ const knob = explicitKnob(config);
241
+ const bad = (what) => ({
242
+ ok: false,
243
+ error: `value for knob "${path}" ${what}`,
244
+ });
245
+ switch (knob.type) {
246
+ case "slider":
247
+ if (!isNum(value))
248
+ return bad("must be a number");
249
+ if (value < knob.min || value > knob.max)
250
+ return bad(`must be within ${knob.min}..${knob.max}`);
251
+ return { ok: true, value };
252
+ case "toggle":
253
+ return typeof value === "boolean" ? { ok: true, value } : bad("must be true or false");
254
+ case "select":
255
+ return typeof value === "string" && optionValues(knob.options).includes(value)
256
+ ? { ok: true, value }
257
+ : bad(`must be one of ${optionValues(knob.options).join(", ")}`);
258
+ case "color":
259
+ return typeof value === "string" && isCssColor(value)
260
+ ? { ok: true, value }
261
+ : bad("must be a CSS color");
262
+ case "text":
263
+ return isStr(value, MAX_TEXT_VALUE) ? { ok: true, value } : bad("must be a string");
264
+ case "image":
265
+ if (!isStr(value, MAX_TEXT_VALUE))
266
+ return bad("must be a string");
267
+ if (knob.options && !optionValues(knob.options).includes(value)) {
268
+ return bad(`must be one of ${optionValues(knob.options).join(", ")}`);
269
+ }
270
+ return { ok: true, value };
271
+ case "pad": {
272
+ if (!value || typeof value !== "object")
273
+ return bad("must be {x, y}");
274
+ const { x, y } = value;
275
+ const [xMin, xMax] = axisOf(knob.x);
276
+ const [yMin, yMax] = axisOf(knob.y);
277
+ if (!isNum(x) || !isNum(y) || x < xMin || x > xMax || y < yMin || y > yMax) {
278
+ return bad("must be {x, y} within the pad's range");
279
+ }
280
+ return { ok: true, value: { x, y } };
281
+ }
282
+ case "spring":
283
+ case "easing": {
284
+ // tunekit lets a transition knob switch between spring and easing modes.
285
+ const checked = checkKnobConfig(path, value);
286
+ if (!checked.ok)
287
+ return checked;
288
+ const t = checked.value;
289
+ return t && typeof t === "object" && (t.type === "spring" || t.type === "easing")
290
+ ? { ok: true, value: t }
291
+ : bad("must be a spring or easing");
292
+ }
293
+ }
294
+ }
295
+ // The declared knob for a path: the mock's own, else a variant's override.
296
+ export function knobFor(path, mock, posts) {
297
+ if (Object.hasOwn(mock.knobs, path))
298
+ return mock.knobs[path];
299
+ for (const p of posts)
300
+ if (p.knobs && Object.hasOwn(p.knobs, path))
301
+ return p.knobs[path];
302
+ return undefined;
303
+ }
304
+ export function checkKnobValues(raw, mock, posts, what) {
305
+ if (raw === undefined || raw === null)
306
+ return { ok: true, value: {} };
307
+ if (typeof raw !== "object" || Array.isArray(raw)) {
308
+ return { ok: false, error: `${what} must be an object of knob path → value` };
309
+ }
310
+ const out = {};
311
+ for (const [path, value] of Object.entries(raw)) {
312
+ const config = knobFor(path, mock, posts);
313
+ if (config === undefined)
314
+ return { ok: false, error: `${what}: no knob "${path}" is declared` };
315
+ const checked = checkKnobValue(path, config, value);
316
+ if (!checked.ok)
317
+ return checked;
318
+ out[path] = checked.value;
319
+ }
320
+ return { ok: true, value: out };
321
+ }
322
+ // How many discrete choices a knob offers, or null when it is continuous. A knob
323
+ // with only a handful is usually a decision dressed as a control (plan D12).
324
+ export function discreteChoices(config) {
325
+ const knob = explicitKnob(config);
326
+ if (knob.type === "toggle")
327
+ return 2;
328
+ if (knob.type === "select")
329
+ return knob.options.length;
330
+ if (knob.type === "image" && knob.options)
331
+ return knob.options.length;
332
+ return null;
333
+ }
@@ -0,0 +1,2 @@
1
+ import type { LogSink } from "./eventLog.ts";
2
+ export declare function createFileLog(path: string, maxBytes?: number): LogSink;
@@ -0,0 +1,27 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ // One file plus one rotated predecessor caps the log at twice this, so leaving
4
+ // it on by default costs a bounded slice of disk.
5
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
6
+ // Appends JSONL to `path`, rolling it to `<path>.1` past `maxBytes`. Writes are
7
+ // synchronous so lines never interleave and a crash loses nothing already
8
+ // answered; the loop's call rate makes that cost negligible.
9
+ export function createFileLog(path, maxBytes = DEFAULT_MAX_BYTES) {
10
+ mkdirSync(dirname(path), { recursive: true });
11
+ let size = 0;
12
+ try {
13
+ size = statSync(path).size;
14
+ }
15
+ catch {
16
+ // No file yet.
17
+ }
18
+ return (entry) => {
19
+ const line = `${JSON.stringify(entry)}\n`;
20
+ if (size > 0 && size + line.length > maxBytes) {
21
+ renameSync(path, `${path}.1`);
22
+ size = 0;
23
+ }
24
+ appendFileSync(path, line);
25
+ size += Buffer.byteLength(line);
26
+ };
27
+ }