@chalky/chalky 0.0.0-stage → 0.1.3

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/app/SYSTEM.md +30 -0
  4. package/app/extensions/appdata/README.md +10 -0
  5. package/app/extensions/appdata/guide.md +4 -0
  6. package/app/extensions/appdata/index.ts +47 -0
  7. package/app/extensions/doodle/README.md +15 -0
  8. package/app/extensions/doodle/browser.js +388 -0
  9. package/app/extensions/doodle/guide.md +6 -0
  10. package/app/extensions/doodle/index.ts +54 -0
  11. package/app/extensions/files/browser.js +96 -0
  12. package/app/extensions/files/guide.md +7 -0
  13. package/app/extensions/files/index.ts +73 -0
  14. package/app/extensions/frames/README.md +37 -0
  15. package/app/extensions/frames/browser.js +163 -0
  16. package/app/extensions/frames/guide.md +7 -0
  17. package/app/extensions/frames/index.ts +76 -0
  18. package/app/extensions/mcp/browser.js +64 -0
  19. package/app/extensions/mcp/guide.md +5 -0
  20. package/app/extensions/mcp/index.ts +64 -0
  21. package/app/extensions/model/index.ts +20 -0
  22. package/app/extensions/page/index.ts +96 -0
  23. package/app/extensions/page/page.html +615 -0
  24. package/app/extensions/point/README.md +22 -0
  25. package/app/extensions/point/browser.js +245 -0
  26. package/app/extensions/point/guide.md +6 -0
  27. package/app/extensions/point/index.ts +97 -0
  28. package/app/extensions/prompt/index.ts +19 -0
  29. package/app/extensions/reload/index.ts +21 -0
  30. package/app/extensions/screen/browser.js +76 -0
  31. package/app/extensions/screen/index.ts +80 -0
  32. package/app/extensions/steps/index.ts +17 -0
  33. package/app/extensions/swarm/README.md +47 -0
  34. package/app/extensions/swarm/guide.md +11 -0
  35. package/app/extensions/swarm/index.ts +304 -0
  36. package/app/extensions/ui/README.md +69 -0
  37. package/app/extensions/ui/agents.js +45 -0
  38. package/app/extensions/ui/browser.js +363 -0
  39. package/app/extensions/ui/components/App.js +28 -0
  40. package/app/extensions/ui/components/Button.js +28 -0
  41. package/app/extensions/ui/components/Card.js +23 -0
  42. package/app/extensions/ui/components/Column.js +17 -0
  43. package/app/extensions/ui/components/Markdown.js +37 -0
  44. package/app/extensions/ui/components/Row.js +17 -0
  45. package/app/extensions/ui/components/Table.js +31 -0
  46. package/app/extensions/ui/components/Text.js +19 -0
  47. package/app/extensions/ui/components/TextField.js +30 -0
  48. package/app/extensions/ui/guide.md +7 -0
  49. package/app/extensions/ui/index.ts +271 -0
  50. package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
  51. package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
  52. package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
  53. package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
  54. package/app/extensions/ui/voice.js +114 -0
  55. package/app/extensions/ui_history/browser.js +97 -0
  56. package/app/extensions/ui_history/index.ts +109 -0
  57. package/app/extensions/versions/browser.js +58 -0
  58. package/app/extensions/versions/index.ts +168 -0
  59. package/app/extensions/viewer/README.md +12 -0
  60. package/app/extensions/viewer/browser.js +322 -0
  61. package/app/extensions/viewer/guide.md +5 -0
  62. package/app/extensions/viewer/index.ts +209 -0
  63. package/app/mcp.json +3 -0
  64. package/app/skills/plugin-map/SKILL.md +49 -0
  65. package/app/skills/write-skill/SKILL.md +57 -0
  66. package/app/ui/apps/.keep +0 -0
  67. package/bin/chalky.mjs +11 -0
  68. package/node_modules/xlsx/CHANGELOG.md +361 -0
  69. package/node_modules/xlsx/LICENSE +201 -0
  70. package/node_modules/xlsx/README.md +41 -0
  71. package/node_modules/xlsx/bin/xlsx.njs +310 -0
  72. package/node_modules/xlsx/bower.json +22 -0
  73. package/node_modules/xlsx/dist/LICENSE +201 -0
  74. package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
  75. package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
  76. package/node_modules/xlsx/dist/cpexcel.js +1506 -0
  77. package/node_modules/xlsx/dist/shim.min.js +2 -0
  78. package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
  79. package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
  80. package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
  81. package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
  82. package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
  83. package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
  84. package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
  85. package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
  86. package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
  87. package/node_modules/xlsx/dist/zahl.d.ts +4 -0
  88. package/node_modules/xlsx/package.json +183 -0
  89. package/node_modules/xlsx/types/index.d.ts +1065 -0
  90. package/node_modules/xlsx/types/tsconfig.json +15 -0
  91. package/node_modules/xlsx/xlsx.js +28103 -0
  92. package/node_modules/xlsx/xlsx.mjs +28228 -0
  93. package/node_modules/xlsx/xlsxworker.js +14 -0
  94. package/package.json +42 -4
  95. package/server.ts +275 -0
@@ -0,0 +1,363 @@
1
+ // Browser half of the ui plugin (a page script, see chat_ui.py). Draws UI surfaces in the "top" slot
2
+ // (the page's main space). A surface is the record described at the top of ui.py.
3
+ // A surface is bare: only what the agent built, no frame (its label shows on hover: name, owner, ✕). It is
4
+ // stacked in the main space, its content keeping its own size; or pinned at place {x, y, width?, height?}
5
+ // (px from the main space's top-left, so it scrolls with the page), its root component then filling it.
6
+ // What the user can do with the label (the handle, so clicks inside still work):
7
+ // drag move it; a drop sends {type: "ui_move", surface, place} (unless draggable: false)
8
+ // drag and hold still over another surface (it lights up), release combine: it slides back and the page
9
+ // fires a "ui-combine" DOM event. A plain drop over another surface just moves it (they overlap)
10
+ // {a, b, x, y} (point.js opens a note box; the agent then changes b, and a stays).
11
+ // Onto a pinned surface: refused (it would change), with a note in the chat
12
+ // hold pin or unpin it ({type: "ui_pin"}): agents can't change a pinned surface, and it stays put
13
+ // double-click turn it over: the back side shows what it is for (about), what it was made from, and
14
+ // each component's levers (sentences from its about block; the words in them change it)
15
+ // ✕ close it; if other surfaces read from it, the label asks first (they get a copy)
16
+ // The corner resizes it (unless resizable: false). Moving or resizing one first pins every stacked surface
17
+ // where it is (else the ones below would jump up).
18
+ // Typing into a bound field changes the data at once and sends {type: "ui_set"} shortly after: saved, and
19
+ // no agent is woken. Props can read other surfaces' data ({"ref", "path"}); those redraw when it changes.
20
+ // Alternatives (options): drawn as a stack with a bar to flip through them; "Keep" sends {type: "ui_keep"}.
21
+ // Components come from the page's catalog (page.load defines <ui-name>; see chat_ui.html); one saved with
22
+ // an older version of its props goes through the component's upgrade(props, fromVersion) first.
23
+ // Elements are kept per id across redraws, so a component keeps its state (focus, a running game).
24
+ const get = (data, path) => path.split("/").filter(Boolean).reduce((o, k) => o?.[k], data);
25
+ const isBinding = (v) => v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).join() === "path";
26
+ const isRef = (v) => v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).sort().join() === "path,ref";
27
+ const propsOf = (c) => Object.fromEntries(Object.entries(c).filter(([k]) => !["id", "component", "children", "child"].includes(k)));
28
+ const refsIn = (s) => new Set(s.components.flatMap((c) => Object.values(propsOf(c)).filter(isRef).map((v) => v.ref)));
29
+ const HOLD_MS = 550; // hold the label this long, without moving, to pin or unpin
30
+ const COMBINE_MS = 600; // hold a dragged surface still over another this long to combine with it
31
+
32
+ const CSS = `
33
+ [data-surface] { position: relative; display: flex; flex-direction: column; width: fit-content; max-width: 100%; margin: 0 0 20px; }
34
+ [data-surface] > .surface-body { flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: flex-start; }
35
+ [data-surface] > .surface-body > * { max-width: 100%; }
36
+ [data-surface][data-placed] > .surface-body { align-items: stretch; }
37
+ [data-surface][data-placed] > .surface-body > * { flex: 1; min-height: 0; }
38
+ [data-surface] > .surface-label { position: absolute; bottom: 100%; left: 0; z-index: 2; display: flex; gap: 8px; align-items: center;
39
+ padding: 2px 8px; border-radius: 8px; font-size: 11px; white-space: nowrap; color: var(--muted, #888); cursor: grab;
40
+ background: var(--glass-strong, #fff); opacity: 0; pointer-events: none; transition: opacity 0.15s; user-select: none; }
41
+ [data-surface]:hover > .surface-label, [data-surface] > .surface-label.asking { opacity: 1; pointer-events: auto; }
42
+ [data-surface] > .surface-label button { color: inherit; font: inherit; background: none; border: 0; cursor: pointer; padding: 0 2px; }
43
+ [data-surface] > .surface-label .ask { color: var(--ink, #1d1d1f); }
44
+ [data-surface] > .surface-resize { position: absolute; right: 0; bottom: 0; z-index: 1; width: 10px; height: 10px;
45
+ border: solid var(--muted, #888); border-width: 0 2px 2px 0; border-radius: 0 0 4px 0; cursor: nwse-resize;
46
+ opacity: 0; pointer-events: none; transition: opacity 0.15s; }
47
+ [data-surface]:hover > .surface-resize { opacity: 1; pointer-events: auto; }
48
+ [data-surface][data-pinned] { outline: 2px solid #d4691e; outline-offset: 4px; border-radius: 6px; }
49
+ [data-surface][data-pinned] > .surface-label { cursor: default; }
50
+ [data-surface][data-pinned] > .surface-resize { display: none; }
51
+ [data-surface][data-drop] { outline: 3px solid var(--accent, #0071e3); outline-offset: 4px; border-radius: 6px; }
52
+ [data-surface][data-flipped] { z-index: 5; } /* the back side stays above its neighbours */
53
+ [data-surface][data-flipped] > .surface-body { visibility: hidden; }
54
+ [data-surface] > .surface-back { position: absolute; left: 0; top: 0; z-index: 1; box-sizing: border-box; min-width: 260px; min-height: 100%;
55
+ max-width: 420px; padding: 12px 14px; border-radius: 12px; font-size: 13px; line-height: 1.5; color: #e8eeea; background: #1f2a25;
56
+ box-shadow: 0 8px 30px rgba(0,0,0,.18); display: flex; flex-direction: column; gap: 8px; }
57
+ [data-surface] > .surface-back p { margin: 0; }
58
+ [data-surface] > .surface-back ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
59
+ [data-surface] > .surface-back .lever { font: inherit; color: #1f2a25; background: #f4d35e; border: 0; border-radius: 4px;
60
+ padding: 0 5px; cursor: pointer; outline-offset: 1px; }
61
+ [data-surface] > .surface-back span.lever { cursor: text; }
62
+ [data-surface] > .surface-back .lever.ref { background: #c7d3cd; cursor: default; }
63
+ [data-surface] > .surface-back .muted { opacity: .7; }
64
+ [data-surface] > .surface-back > button { align-self: flex-start; font: inherit; color: inherit; background: none;
65
+ border: 1px solid currentColor; border-radius: 8px; padding: 3px 10px; cursor: pointer; }
66
+ [data-surface][data-options] > .surface-body { box-shadow: 8px 8px 0 -2px rgba(0,0,0,.07), 16px 16px 0 -4px rgba(0,0,0,.05); border-radius: 10px; }
67
+ [data-surface] > .surface-options { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; font-size: 12px; color: var(--muted, #888); }
68
+ [data-surface] > .surface-options button { font: inherit; color: inherit; background: var(--glass-strong, #fff);
69
+ border: 1px solid var(--line, #ddd); border-radius: 8px; padding: 1px 8px; cursor: pointer; }
70
+ [data-surface] > .surface-options button.keep { color: #fff; background: var(--accent, #0071e3); border-color: transparent; }`;
71
+
72
+ function setPath(data, path, value) {
73
+ const keys = path.split("/").filter(Boolean), last = keys.pop();
74
+ for (const k of keys) data = data[k] ??= {};
75
+ data[last] = value;
76
+ }
77
+
78
+ function el(tag, attrs = {}, ...children) { // a small element builder (textContent, never innerHTML, for data)
79
+ const e = document.createElement(tag);
80
+ for (const [k, v] of Object.entries(attrs)) k === "text" ? (e.textContent = v) : k.startsWith("on") ? (e[k] = v) : e.setAttribute(k, v);
81
+ e.append(...children);
82
+ return e;
83
+ }
84
+
85
+ export default async function (page) {
86
+ if (!page.load) return; // an older page (e.g. a Versions preview) without the component runtime
87
+ document.head.insertAdjacentHTML("beforeend", `<style id="ui-surface-css">${CSS}</style>`);
88
+ const area = page.slot("top");
89
+ const surfaces = {}; // id -> the record from ui.py, plus what only this page has: box, body, nodes, flipped, option
90
+ let abouts = {}, mtimes = {};
91
+ const modules = {}; // component name -> its module (for upgrade())
92
+ const loadAbouts = async () => {
93
+ try { [abouts, mtimes] = await Promise.all(["/ui-api/about", "/ui-api/catalog"].map(async (u) => (await fetch(u)).json())); } catch {}
94
+ };
95
+ page.on("version", loadAbouts); // an agent may have changed a component
96
+ const pending = {}; // "surface path" -> timer: ui_set waits until the typing pauses
97
+ const sendSet = (id, path, value) => {
98
+ clearTimeout(pending[`${id} ${path}`]);
99
+ pending[`${id} ${path}`] = setTimeout(() => page.send({ type: "ui_set", surface: id, path, value }), 500);
100
+ };
101
+
102
+ async function upgraded(name, props, s) { // props saved with an older about.version: through upgrade() first
103
+ const from = s.kinds?.[name], now = abouts[name]?.version;
104
+ if (!from || !now || from >= now) return props;
105
+ try {
106
+ const mod = await (modules[name] ??= import(`/ui/components/${name}.js?v=${mtimes[name]}`));
107
+ return mod.upgrade ? mod.upgrade(props, from) : props;
108
+ } catch (e) { console.warn(`upgrade of ${name}:`, e); return props; }
109
+ }
110
+
111
+ async function draw(id, chain = new Set()) {
112
+ const s = surfaces[id];
113
+ if (!s || chain.has(id)) return; // chain: surfaces reading from each other are drawn once each
114
+ chain.add(id);
115
+ if (!s.box) { // the surface, with a label (name, pin, ✕) that shows on hover
116
+ s.box = el("div");
117
+ s.box.dataset.surface = id; // lets the page work out what a click points at (point.js)
118
+ s.label = el("div", { class: "surface-label" });
119
+ s.body = el("div", { class: "surface-body" });
120
+ s.box.append(s.label, s.body, el("div", { class: "surface-resize", title: "Drag to resize", onpointerdown: (e) => resize(s, id, e) }));
121
+ s.label.onpointerdown = (e) => drag(s, id, e);
122
+ s.label.ondblclick = () => { s.flipped = !s.flipped; draw(id); };
123
+ s.nodes = {};
124
+ area.append(s.box);
125
+ }
126
+ drawLabel(s, id);
127
+ s.box.dataset.owner = s.owner; // who a note on this surface goes to (point.js)
128
+ s.box.querySelector(".surface-resize").hidden = s.resizable === false;
129
+ s.box.toggleAttribute("data-pinned", !!s.pinned);
130
+ s.box.toggleAttribute("data-flipped", !!s.flipped);
131
+ s.box.toggleAttribute("data-options", !!s.options);
132
+ const p = s.place;
133
+ s.box.toggleAttribute("data-placed", !!p);
134
+ Object.assign(s.box.style, p ? { position: "absolute", left: `${p.x}px`, top: `${p.y}px`, margin: "0",
135
+ width: p.width ? `${p.width}px` : "", height: p.height ? `${p.height}px` : "" }
136
+ : { position: "", left: "", top: "", margin: "", width: "", height: "" });
137
+ drawOptionsBar(s, id);
138
+ const shown = s.options ? s.options[s.option ?? 0] : s; // an alternative, or the surface itself
139
+ try {
140
+ for (const c of shown.components) if (!(await page.load(c.component))) throw new Error(`no component ${c.component}`);
141
+ } catch (e) {
142
+ s.body.textContent = `Could not load a component: ${e}`;
143
+ return;
144
+ }
145
+ const byId = Object.fromEntries(shown.components.map((c) => [c.id, c]));
146
+ const resolve = (v) => (isBinding(v) ? get(shown.data, v.path) : isRef(v) ? get(surfaces[v.ref]?.data ?? {}, v.path) : v);
147
+ const props = {};
148
+ for (const c of shown.components) props[c.id] = await upgraded(c.component, Object.fromEntries(
149
+ Object.entries(propsOf(c)).map(([k, v]) => [k, resolve(v)])), s);
150
+
151
+ const build = (cid) => {
152
+ const c = byId[cid], tag = `ui-${c.component.toLowerCase()}`;
153
+ const node = s.nodes[cid]?.localName === tag ? s.nodes[cid] : document.createElement(tag);
154
+ s.nodes[cid] = node;
155
+ node.dataset.cid = cid;
156
+ const children = (c.children || (c.child ? [c.child] : [])).map(build);
157
+ node.update(props[cid], {
158
+ children,
159
+ mount(container) { // only re-insert children when they changed (moving a node loses its focus)
160
+ if (container.children.length !== children.length || children.some((ch, i) => container.children[i] !== ch))
161
+ container.replaceChildren(...children);
162
+ },
163
+ set(prop, value) { // write a bound prop into the data: at once here, saved shortly after (ui_set)
164
+ if (!isBinding(c[prop])) return; // a plain value or another surface's data: not this field's to write
165
+ setPath(shown.data, c[prop].path, value);
166
+ if (!s.options) sendSet(id, c[prop].path, value);
167
+ draw(id);
168
+ },
169
+ action(name, context = {}) {
170
+ const ctx = Object.fromEntries(Object.entries(context).map(([k, v]) => [k, resolve(v)]));
171
+ page.send({ type: "ui_action", surface: id, component: cid, name, context: ctx, data: s.data });
172
+ },
173
+ });
174
+ return node;
175
+ };
176
+ try {
177
+ const root = build("root");
178
+ if (s.body.firstChild !== root) s.body.replaceChildren(root);
179
+ } catch (e) {
180
+ s.body.textContent = `Could not draw this surface: ${e}`;
181
+ }
182
+ drawBack(s, id, shown, resolve);
183
+ for (const [other, o] of Object.entries(surfaces)) if (o.components && refsIn(o).has(id)) draw(other, chain);
184
+ }
185
+
186
+ function drawLabel(s, id) { // name · owner, pinned or not, ✕ (which asks first if others read from it)
187
+ const users = Object.keys(surfaces).filter((o) => o !== id && surfaces[o].components && refsIn(surfaces[o]).has(id));
188
+ s.label.classList.remove("asking");
189
+ s.label.replaceChildren(
190
+ el("span", { text: `${id} · ${s.owner}${s.pinned ? " · pinned" : ""}`, title: "Drag to move; hold still over another to combine; hold to pin; double-click to turn over" }),
191
+ el("button", { text: "✕", title: "Close", onclick: () => {
192
+ if (!users.length) return page.send({ type: "ui_close", surface: id });
193
+ s.label.classList.add("asking");
194
+ s.label.replaceChildren(
195
+ el("span", { class: "ask", text: `${users.join(", ")} ${users.length > 1 ? "read" : "reads"} from this. Copy it in, then close?` }),
196
+ el("button", { text: "Copy & close", onclick: () => page.send({ type: "ui_close", surface: id }) }),
197
+ el("button", { text: "Cancel", onclick: () => drawLabel(s, id) }));
198
+ } }));
199
+ }
200
+
201
+ function drawOptionsBar(s, id) {
202
+ s.box.querySelector(".surface-options")?.remove();
203
+ if (!s.options) return;
204
+ const i = s.option ?? 0, n = s.options.length;
205
+ const flip = (d) => { s.option = (i + d + n) % n; draw(id); };
206
+ const bar = el("div", { class: "surface-options" },
207
+ el("button", { text: "‹", title: "Previous", "aria-label": "Previous option", onclick: () => flip(-1) }),
208
+ el("span", { text: `${i + 1} of ${n}: ${s.options[i].label}` }),
209
+ el("button", { text: "›", title: "Next", "aria-label": "Next option", onclick: () => flip(1) }),
210
+ el("button", { class: "keep", text: "Keep this", onclick: () => page.send({ type: "ui_keep", surface: id, index: i }) }));
211
+ bar.onwheel = (e) => { e.preventDefault(); flip(Math.sign(e.deltaY || e.deltaX)); };
212
+ s.box.insertBefore(bar, s.body);
213
+ }
214
+
215
+ function drawBack(s, id, shown, resolve) { // the back side: about, made from, levers, pinned
216
+ s.box.querySelector(".surface-back")?.remove();
217
+ if (!s.flipped) return;
218
+ const back = el("div", { class: "surface-back" }, el("b", { text: `How ${id} works` }),
219
+ el("p", { class: s.about ? "" : "muted", text: s.about || "No description yet. Ask its agent to write one." }));
220
+ if (s.made_from) back.append(el("p", { text: `Made from ${s.made_from.join(" and ")}` + (s.instruction ? `: “${s.instruction}”` : ".") }));
221
+ const list = el("ul");
222
+ for (const c of shown.components) {
223
+ const spec = abouts[c.component];
224
+ for (const sentence of spec?.levers || []) {
225
+ const li = el("li", {}, el("span", { class: "muted", text: `${c.id}: ` }));
226
+ for (const [i, part] of sentence.split(/\{(\w+)\}/).entries()) li.append(i % 2 ? lever(s, id, shown, c, part, spec.props?.[part] || {}, resolve) : part);
227
+ list.append(li);
228
+ }
229
+ }
230
+ if (list.children.length) back.append(list);
231
+ back.append(el("p", {}, "It is ", el("button", { class: "lever", text: s.pinned ? "pinned" : "not pinned",
232
+ onclick: () => page.send({ type: "ui_pin", surface: id, pinned: !s.pinned }) }),
233
+ `: agents ${s.pinned ? "can't" : "may"} change it.`));
234
+ back.append(el("button", { text: "Turn back", onclick: () => { s.flipped = false; draw(id); } }));
235
+ s.box.append(back);
236
+ }
237
+
238
+ function lever(s, id, shown, c, prop, spec, resolve) { // one {prop} of a lever sentence: a control
239
+ const raw = c[prop], value = resolve(raw) ?? spec.default ?? "";
240
+ if (isRef(raw)) return el("span", { class: "lever ref", text: String(value), title: `read from ${raw.ref}` });
241
+ const commit = (v) => {
242
+ if (isBinding(raw)) { setPath(shown.data, raw.path, v); page.send({ type: "ui_set", surface: id, path: raw.path, value: v }); }
243
+ else { c[prop] = v; page.send({ type: "ui_prop", surface: id, component: c.id, prop, value: v }); }
244
+ draw(id);
245
+ };
246
+ if (spec.type === "choice" && spec.options?.length)
247
+ return el("button", { class: "lever", text: String(value), onclick: () => commit(spec.options[(spec.options.indexOf(value) + 1) % spec.options.length]) });
248
+ if (spec.type === "boolean") return el("button", { class: "lever", text: value ? "yes" : "no", onclick: () => commit(!value) });
249
+ if (spec.type === "text" || spec.type === "number") {
250
+ const span = el("span", { class: "lever", contenteditable: "true", spellcheck: "false", text: String(value) });
251
+ span.onkeydown = (e) => { if (e.key === "Enter") { e.preventDefault(); span.blur(); } };
252
+ span.onblur = () => {
253
+ const v = spec.type === "number" ? Number(span.textContent.trim()) : span.textContent.trim();
254
+ if (v !== value && !(spec.type === "number" && Number.isNaN(v))) commit(v);
255
+ };
256
+ return span;
257
+ }
258
+ return el("span", { class: "lever ref", text: JSON.stringify(value) }); // lists, objects: shown, changed by the agent
259
+ }
260
+
261
+ page.on("ui", (ev) => {
262
+ if (ev.op === "render") {
263
+ const old = surfaces[ev.surface] || {};
264
+ const { op, type, surface, ...record } = ev;
265
+ surfaces[ev.surface] = { ...record, box: old.box, label: old.label, body: old.body, nodes: old.nodes,
266
+ flipped: old.flipped, option: record.options ? old.option : 0 };
267
+ draw(ev.surface);
268
+ } else if (ev.op === "delete" && surfaces[ev.surface]) {
269
+ surfaces[ev.surface].box?.remove();
270
+ delete surfaces[ev.surface];
271
+ for (const id of Object.keys(surfaces)) drawLabel(surfaces[id], id);
272
+ } else if (ev.op === "reset") { // a whole saved state (undo, redo, rewind)
273
+ for (const id of Object.keys(surfaces)) if (!(id in ev.surfaces)) { surfaces[id].box?.remove(); delete surfaces[id]; }
274
+ for (const [id, record] of Object.entries(ev.surfaces)) {
275
+ const old = surfaces[id] || {};
276
+ surfaces[id] = { ...record, box: old.box, label: old.label, body: old.body, nodes: old.nodes, flipped: old.flipped, option: 0 };
277
+ }
278
+ for (const id of Object.keys(surfaces)) draw(id);
279
+ }
280
+ });
281
+
282
+ function pinStacked() { // pin each stacked surface where it is now, so taking one out of the stack moves none
283
+ const stacked = Object.entries(surfaces).filter(([, s]) => !s.place && s.box);
284
+ for (const [, s] of stacked) s.place = { x: s.box.offsetLeft, y: s.box.offsetTop }; // read all, then move
285
+ for (const [id, s] of stacked) {
286
+ s.box.toggleAttribute("data-placed", true);
287
+ Object.assign(s.box.style, { position: "absolute", margin: "0", left: `${s.place.x}px`, top: `${s.place.y}px` });
288
+ page.send({ type: "ui_move", surface: id, place: s.place });
289
+ }
290
+ }
291
+
292
+ function surfaceAt(x, y, except) { // the surface under a window point, other than `except`
293
+ return document.elementsFromPoint(x, y).map((e) => e.closest?.("[data-surface]")).find((b) => b && b !== except) || null;
294
+ }
295
+
296
+ function drag(s, id, e) { // hold: pin; move: drag it; hold still over another surface, then release: combine
297
+ if (e.target.closest("button")) return;
298
+ const handle = e.currentTarget, box = s.box;
299
+ handle.setPointerCapture(e.pointerId); // keep getting moves over iframes and other surfaces
300
+ let place = null, held = false, x0 = 0, y0 = 0;
301
+ let under = null, armed = null, wait = null, sx = 0, sy = 0; // the surface under the pointer; armed: lit, ready to combine
302
+ const hold = setTimeout(() => { held = true; page.send({ type: "ui_pin", surface: id, pinned: !s.pinned }); }, HOLD_MS);
303
+ handle.onpointermove = (ev) => {
304
+ if (held || Math.hypot(ev.clientX - e.clientX, ev.clientY - e.clientY) < 5) return;
305
+ clearTimeout(hold);
306
+ if (s.pinned || s.draggable === false) return;
307
+ if (!place) { pinStacked(); x0 = box.offsetLeft; y0 = box.offsetTop; box.style.zIndex = 4; } // offsets in the main space; above the rest while dragged
308
+ place = { ...s.place, x: Math.round(x0 + ev.clientX - e.clientX), y: Math.round(y0 + ev.clientY - e.clientY) };
309
+ box.toggleAttribute("data-placed", true);
310
+ Object.assign(box.style, { position: "absolute", margin: "0", left: `${place.x}px`, top: `${place.y}px` });
311
+ const now = surfaceAt(ev.clientX, ev.clientY, box);
312
+ if (now !== under || Math.hypot(ev.clientX - sx, ev.clientY - sy) > 6) { // moved: start waiting again
313
+ clearTimeout(wait);
314
+ armed?.removeAttribute("data-drop");
315
+ armed = null, under = now, sx = ev.clientX, sy = ev.clientY;
316
+ if (now) wait = setTimeout(() => { armed = now; now.setAttribute("data-drop", ""); }, COMBINE_MS);
317
+ }
318
+ };
319
+ handle.onpointerup = (ev) => {
320
+ clearTimeout(hold);
321
+ clearTimeout(wait);
322
+ handle.onpointermove = handle.onpointerup = null;
323
+ armed?.removeAttribute("data-drop");
324
+ if (!place) return; // a click or a hold, not a drag
325
+ box.style.zIndex = "";
326
+ if (armed) { // combine: put this one back, and ask how (point.js); b is the one that changes
327
+ Object.assign(box.style, { left: `${s.place.x}px`, top: `${s.place.y}px` });
328
+ const b = armed.dataset.surface;
329
+ if (surfaces[b]?.pinned) return page.note(`${b} is pinned, so it can't take ${id} in. Hold its label to unpin it first.`);
330
+ document.dispatchEvent(new CustomEvent("ui-combine", { detail: { a: id, b, x: ev.clientX, y: ev.clientY } }));
331
+ return;
332
+ }
333
+ s.place = place;
334
+ page.send({ type: "ui_move", surface: id, place });
335
+ };
336
+ }
337
+
338
+ function resize(s, id, e) { // drag the corner: the surface's new width and height; on release, pin it so
339
+ if (s.pinned) return;
340
+ pinStacked();
341
+ const handle = e.currentTarget, box = s.box;
342
+ const x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
343
+ handle.setPointerCapture(e.pointerId);
344
+ let place = null;
345
+ handle.onpointermove = (ev) => {
346
+ place = { x, y, width: Math.min(area.clientWidth - x, Math.max(40, Math.round(w0 + ev.clientX - e.clientX))),
347
+ height: Math.max(30, Math.round(h0 + ev.clientY - e.clientY)) };
348
+ box.toggleAttribute("data-placed", true);
349
+ Object.assign(box.style, { position: "absolute", margin: "0", left: `${x}px`, top: `${y}px`,
350
+ width: `${place.width}px`, height: `${place.height}px` });
351
+ };
352
+ handle.onpointerup = () => {
353
+ handle.onpointermove = handle.onpointerup = null;
354
+ if (!place) return;
355
+ s.place = place;
356
+ page.send({ type: "ui_move", surface: id, place });
357
+ };
358
+ }
359
+
360
+ await loadAbouts();
361
+ for (const [id, s] of Object.entries(await (await fetch("/ui-api/surfaces")).json())) surfaces[id] = { ...s, option: 0 };
362
+ for (const id of Object.keys(surfaces)) draw(id);
363
+ }
@@ -0,0 +1,28 @@
1
+ // App: shows a full mini-site from {code}/ui/apps/<name>/ in a frame. For big things (multi-file games, editors)
2
+ // that don't fit one component. Put index.html (and its scripts) in {code}/ui/apps/<name>/.
3
+ // No frame of its own and a clear background: only the app's page shows (keep that page's background
4
+ // transparent unless the app needs one). As wide as its surface; it fills its surface's placed area,
5
+ // else it is `height` px tall. The app can talk to the agents and see their messages: /ui/agents.js.
6
+ export const about = {
7
+ "version": 1,
8
+ "props": {
9
+ "src": {"type": "text", "doc": "path inside {code}/ui/apps, e.g. \"snake/index.html\""},
10
+ "height": {"type": "number", "default": 400, "doc": "px, when nothing else sizes it"}
11
+ },
12
+ "children": "none",
13
+ "levers": ["Shows {src}.", "{height} px tall when not placed."],
14
+ "example": {"src": "snake/index.html", "height": 400}
15
+ };
16
+ export default class extends HTMLElement {
17
+ update({ src = "", height = 400 }) {
18
+ if (!this.frame) {
19
+ this.style.cssText = "display:flex;flex-direction:column;align-self:stretch";
20
+ this.frame = document.createElement("iframe");
21
+ this.frame.style.cssText = "width:100%;min-height:0;border:0;background:transparent;color-scheme:normal";
22
+ this.append(this.frame);
23
+ }
24
+ this.frame.style.flex = `1 1 ${height}px`; // height when nothing sizes it; grows or shrinks to a placed area
25
+ const url = `/ui/apps/${src}`;
26
+ if (this.frame.getAttribute("src") !== url) this.frame.setAttribute("src", url); // don't restart a running app
27
+ }
28
+ }
@@ -0,0 +1,28 @@
1
+ // Button: a button that sends an action to the agent when clicked.
2
+ export const about = {
3
+ "version": 1,
4
+ "props": {
5
+ "label": {"type": "text", "default": "OK"},
6
+ "action": {"type": "action", "default": "click", "doc": "the action name you will receive"},
7
+ "context": {"type": "object", "doc": "sent with the action; values may be {\"path\": ...}"},
8
+ "variant": {"type": "choice", "options": ["primary", "secondary"], "default": "primary"}
9
+ },
10
+ "children": "none",
11
+ "levers": ["Says {label}.", "Looks {variant}."],
12
+ "example": {"label": "Send order", "action": "send", "variant": "primary"}
13
+ };
14
+ export default class extends HTMLElement {
15
+ update(props, ui) {
16
+ if (!this.button) {
17
+ this.button = document.createElement("button");
18
+ this.button.onclick = () => this.ui.action(this.props.action || "click", this.props.context || {});
19
+ this.append(this.button);
20
+ }
21
+ this.props = props;
22
+ this.ui = ui;
23
+ this.button.textContent = props.label || "OK";
24
+ this.button.style.cssText = "border-radius:980px;padding:7px 16px;font-weight:500;" + (props.variant === "secondary"
25
+ ? "background:var(--glass-strong);color:var(--accent);border:1px solid var(--line)"
26
+ : "background:var(--accent);color:#fff;border:0");
27
+ }
28
+ }
@@ -0,0 +1,23 @@
1
+ // Card: a glass panel with an optional title; its children are stacked inside.
2
+ export const about = {
3
+ "version": 1,
4
+ "props": {
5
+ "title": {"type": "text", "doc": "optional"}
6
+ },
7
+ "children": "many",
8
+ "levers": ["Titled {title}."],
9
+ "example": {"title": "Lunch orders"}
10
+ };
11
+ export default class extends HTMLElement {
12
+ update({ title = "" }, ui) {
13
+ if (!this.body) {
14
+ this.style.cssText = "display:block;padding:14px 16px;border-radius:var(--radius);background:var(--glass-strong);" +
15
+ "border:1px solid var(--edge);box-shadow:var(--shadow);backdrop-filter:var(--blur)";
16
+ this.innerHTML = `<div style="font-weight:600;margin-bottom:8px"></div><div style="display:flex;flex-direction:column;gap:8px"></div>`;
17
+ this.body = this.lastChild;
18
+ }
19
+ this.firstChild.textContent = title;
20
+ this.firstChild.hidden = !title;
21
+ ui.mount(this.body);
22
+ }
23
+ }
@@ -0,0 +1,17 @@
1
+ // Column: stacks its children top to bottom.
2
+ export const about = {
3
+ "version": 1,
4
+ "props": {
5
+ "gap": {"type": "number", "default": 8, "doc": "px between children"},
6
+ "align": {"type": "choice", "options": ["start", "center", "end", "stretch"], "default": "stretch"}
7
+ },
8
+ "children": "many",
9
+ "levers": ["Children {gap} px apart, aligned {align}."],
10
+ "example": {"gap": 8}
11
+ };
12
+ export default class extends HTMLElement {
13
+ update({ gap = 8, align = "stretch" }, ui) {
14
+ this.style.cssText = `display:flex;flex-direction:column;gap:${gap}px;align-items:${align}`;
15
+ ui.mount(this);
16
+ }
17
+ }
@@ -0,0 +1,37 @@
1
+ // Markdown: formatted text from a markdown string (headings, lists, tables, code, links, quotes).
2
+ // HTML in the text is sanitised; links open in a new tab. The chat draws message bodies with it too.
3
+ export const about = {
4
+ "version": 1,
5
+ "props": {
6
+ "text": {"type": "text", "doc": "markdown"}
7
+ },
8
+ "children": "none",
9
+ "levers": [],
10
+ "example": {"text": "**Lunch** closes *Friday 11:00*"}
11
+ };
12
+ import { marked } from "marked"; // bare names: resolved by the import map in chat_ui.html
13
+ import DOMPurify from "dompurify";
14
+
15
+ const CSS = `
16
+ ui-markdown { display: block; white-space: normal; }
17
+ ui-markdown p, ui-markdown ul, ui-markdown ol, ui-markdown pre, ui-markdown table, ui-markdown blockquote { margin: 6px 0; }
18
+ ui-markdown ul, ui-markdown ol { padding-left: 22px; }
19
+ ui-markdown h1, ui-markdown h2, ui-markdown h3, ui-markdown h4 { font-size: 1.05em; margin: 8px 0 4px; }
20
+ ui-markdown code { font: 12.5px monospace; background: rgba(0, 0, 0, .06); padding: 1px 4px; border-radius: 3px; }
21
+ ui-markdown pre { background: var(--hover); padding: 8px; border-radius: 4px; overflow-x: auto; }
22
+ ui-markdown pre code { background: none; padding: 0; }
23
+ ui-markdown table { border-collapse: collapse; }
24
+ ui-markdown th, ui-markdown td { border: 1px solid var(--line); padding: 3px 8px; }
25
+ ui-markdown blockquote { border-left: 3px solid var(--line); padding-left: 8px; color: var(--soft); }`;
26
+
27
+ export default class extends HTMLElement {
28
+ update({ text = "" }) {
29
+ if (!document.getElementById("ui-markdown-css")) {
30
+ document.head.insertAdjacentHTML("beforeend", `<style id="ui-markdown-css">${CSS}</style>`);
31
+ }
32
+ if (this.state?.text === text) return; // unchanged: keep the DOM (and any selection)
33
+ this.state = { text };
34
+ this.innerHTML = DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: true }));
35
+ for (const a of this.querySelectorAll("a")) { a.target = "_blank"; a.rel = "noopener"; }
36
+ }
37
+ }
@@ -0,0 +1,17 @@
1
+ // Row: places its children side by side (wraps when out of space).
2
+ export const about = {
3
+ "version": 1,
4
+ "props": {
5
+ "gap": {"type": "number", "default": 8, "doc": "px between children"},
6
+ "align": {"type": "choice", "options": ["start", "center", "end", "stretch"], "default": "center"}
7
+ },
8
+ "children": "many",
9
+ "levers": ["Children {gap} px apart, aligned {align}."],
10
+ "example": {"gap": 8}
11
+ };
12
+ export default class extends HTMLElement {
13
+ update({ gap = 8, align = "center" }, ui) {
14
+ this.style.cssText = `display:flex;flex-wrap:wrap;gap:${gap}px;align-items:${align}`;
15
+ ui.mount(this);
16
+ }
17
+ }
@@ -0,0 +1,31 @@
1
+ // Table: rows of data under column headers.
2
+ export const about = {
3
+ "version": 1,
4
+ "props": {
5
+ "columns": {"type": "list", "doc": "column headers"},
6
+ "rows": {"type": "rows", "doc": "one array per row, a value per column; often {\"path\": \"/rows\"}"}
7
+ },
8
+ "children": "none",
9
+ "levers": [],
10
+ "accepts": "rows: a list with one array per row",
11
+ "example": {"columns": ["Name", "Dish", "S$"], "rows": [["S. Tan", "Laksa", 6.0], ["R. Lim", "Chicken rice", 5.5]]}
12
+ };
13
+ export default class extends HTMLElement {
14
+ update({ columns = [], rows = [] }) {
15
+ const table = document.createElement("table");
16
+ table.style.cssText = "border-collapse:collapse;width:100%;font-size:13px;font-variant-numeric:tabular-nums";
17
+ const cell = (tag, text) => {
18
+ const c = document.createElement(tag);
19
+ c.textContent = text ?? "";
20
+ c.style.cssText = "border-bottom:1px solid var(--line);padding:6px 10px;text-align:left" +
21
+ (tag === "th" ? ";color:var(--muted);font-weight:500" : "");
22
+ return c;
23
+ };
24
+ table.append(...[columns, ...rows].map((row, i) => {
25
+ const tr = document.createElement("tr");
26
+ tr.append(...row.map((v) => cell(i ? "td" : "th", v)));
27
+ return tr;
28
+ }));
29
+ this.replaceChildren(table);
30
+ }
31
+ }
@@ -0,0 +1,19 @@
1
+ // Text: a block of text.
2
+ export const about = {
3
+ "version": 1,
4
+ "props": {
5
+ "text": {"type": "text", "doc": "the words"},
6
+ "variant": {"type": "choice", "options": ["h1", "h2", "body", "caption"], "default": "body"}
7
+ },
8
+ "children": "none",
9
+ "levers": ["Shown as {variant}."],
10
+ "example": {"text": "Lunch closes Friday 11:00", "variant": "body"}
11
+ };
12
+ const STYLE = { h1: "font-size:22px;font-weight:600;letter-spacing:-0.02em", h2: "font-size:17px;font-weight:600", body: "", caption: "font-size:12px;color:var(--muted)" };
13
+
14
+ export default class extends HTMLElement {
15
+ update({ text = "", variant = "body" }) {
16
+ this.style.cssText = "display:block;white-space:pre-wrap;" + (STYLE[variant] || "");
17
+ this.textContent = text;
18
+ }
19
+ }
@@ -0,0 +1,30 @@
1
+ // TextField: a labelled text input. Bind value to data so the agent gets what the user typed.
2
+ export const about = {
3
+ "version": 1,
4
+ "props": {
5
+ "label": {"type": "text"},
6
+ "value": {"type": "text", "doc": "usually {\"path\": \"/name\"}, so you get what the user typed"},
7
+ "placeholder": {"type": "text"},
8
+ "multiline": {"type": "boolean", "default": false}
9
+ },
10
+ "children": "none",
11
+ "levers": ["Labelled {label}.", "Hint: {placeholder}.", "Several lines: {multiline}."],
12
+ "example": {"label": "Your name", "value": "", "placeholder": "e.g. S. Tan"}
13
+ };
14
+ export default class extends HTMLElement {
15
+ update({ label = "", value = "", placeholder = "", multiline = false }, ui) {
16
+ if (!this.input) {
17
+ this.style.cssText = "display:flex;flex-direction:column;gap:4px";
18
+ this.innerHTML = `<label style="font-size:12px;color:var(--muted)"></label>`;
19
+ this.input = document.createElement(multiline ? "textarea" : "input");
20
+ this.input.style.cssText = "padding:8px 10px;font:inherit;color:inherit;border-radius:10px;" +
21
+ "background:var(--glass-strong);border:1px solid var(--line)";
22
+ this.input.oninput = () => this.ui.set("value", this.input.value);
23
+ this.append(this.input);
24
+ }
25
+ this.ui = ui;
26
+ this.firstChild.textContent = label;
27
+ this.input.placeholder = placeholder;
28
+ if (document.activeElement !== this.input) this.input.value = value ?? ""; // don't fight the user's typing
29
+ }
30
+ }
@@ -0,0 +1,7 @@
1
+ # Showing UI to the user
2
+ Show what you build as UI surfaces: cards in the page's main space, described as JSON (ui_catalog, ui_render,
3
+ ui_update, ui_delete), rather than by editing the page. Load the ui-design skill before building or restyling.
4
+ - Name what you build (surface ids, element ids): the user points at things by name.
5
+ - The user moves and resizes surfaces: build things that fill their box (%, flex, a viewBox), not fixed px.
6
+ - A surface the user pinned: don't work around it (make a new one, made_from=[it], or ask).
7
+ Before your first ui_render in a conversation, and before writing a component or an App, read README.md.