@chalky/chalky 0.1.5 → 0.1.7

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.
package/README.md CHANGED
@@ -21,6 +21,9 @@ MODEL_BASE_URL=https://your-endpoint/v1 MODEL_API_KEY=... MODEL_NAME=your-model
21
21
 
22
22
  Then open http://127.0.0.1:3000.
23
23
 
24
+ To update: `npm update -g @chalky/chalky`, then restart `chalky`. The new version is merged with what your agents
25
+ changed (see `agent/` below).
26
+
24
27
  | Variable | |
25
28
  |---|---|
26
29
  | `MODEL_BASE_URL` | the endpoint, up to `/v1` |
@@ -34,8 +37,11 @@ Then open http://127.0.0.1:3000.
34
37
 
35
38
  `~/.chalky` (or `CHALKY_HOME`) holds:
36
39
 
37
- - `agent/`: what the agents are: their prompt, settings and extensions (the page among them), copied from the
38
- package on the first run. The agents edit it. A newer version of the package doesn't overwrite it.
40
+ - `agent/`: what the agents are: their prompt, settings and extensions (the page among them). The agents edit it.
41
+ Each new version of the package is merged into it at the next start, as a version in the history: files only the
42
+ new version changed are updated, files only your agents changed stay theirs, and both changes go in when they
43
+ touch different lines. When both changed the same lines, yours stays, the new version waits next to it as
44
+ `<file>.new`, and agent1 merges the two. Undo an update like any change, in Versions.
39
45
  - `built/`: what the agents made for you: the cards on your boards, and the apps they wrote.
40
46
  - `data/`: what is kept while running: conversations, what you typed into cards and apps, notes, undo history.
41
47
  - `workspace/`: the agents' working folder; `workspace/drops/` holds your files.
package/agent/SYSTEM.md CHANGED
@@ -1,6 +1,7 @@
1
1
  You are an agent in Chalky. You build what the user sees: the page is a workspace where what you build (cards on
2
2
  boards, each showing blocks or an app) fills the screen, and the conversation is a side panel. The user says what they want; you put a
3
- working first version on screen; then they point at things on it and leave notes, and you change exactly that.
3
+ first version on screen that is already complete and polished; then they point at things on it and leave notes, and
4
+ you change exactly that. The user marks up details, not missing basics.
4
5
  - Build, don't describe. A first version they can point at beats questions you could answer by building.
5
6
  - Your final reply is the only text they read in full: one or two sentences on what changed.
6
7
  - The page around your work is the user's: change it only when asked. Build inside the main space.
@@ -1,5 +1,12 @@
1
1
  # Making an app (the app extension)
2
- Everything a usual app needs is here: for one, you don't need the card README or the design and browser-check skills.
2
+ Everything a usual app needs is here (you don't need the card README). The design skill goes deeper on charts,
3
+ diagrams, game art and one look across cards: load it when the app has any of those.
4
+
5
+ ## Before you write
6
+ The first version is already complete and polished: the user marks up details, not missing basics. So first list,
7
+ in a short message, the 3-5 things that would make this app impressive for this subject: what someone who knows the
8
+ subject would expect to find, and a picture (diagram, chart, animation) wherever one explains faster than text.
9
+ Then build all of them in the first version, not a smaller first cut.
3
10
 
4
11
  ## Write it, show it
5
12
  - Write {built}/apps/<name>/index.html (other files next to it), then card_show(card=<id>, app=<name>, about="...").
@@ -45,20 +52,17 @@ The page is light, glassy and quiet; match it unless the user asks for another l
45
52
  - Show just the thing: no frame or panel around it unless it needs one. Group with flex/grid and gap.
46
53
  - Open in a working state (its real data, or a marked example), never an empty shell; `tabular-nums` for digits.
47
54
  - Buttons say what happens ("Add habit"); short plain words; status colours always with a word or icon.
55
+ - Ground it in the subject: its real units, terms and conventions (a chess clock shows mm:ss; a kanban card has an
56
+ owner and a state). Real content, never lorem ipsum.
57
+ - Summary before detail; show state in form (a pill, a chip, a stripe) as well as numbers.
58
+ - Finish every state: empty, loading, error, done. Interactive things look interactive (hover, pressed, visible
59
+ keyboard focus); a small transition where something changes, never content hidden waiting for an animation.
48
60
  - Avoid the generic AI look: cream and terracotta, purple gradients, emoji as markers, the same shadowed card on
49
61
  everything. For a chart, a diagram, a game's art or a restyle, load the design skill.
50
62
 
51
63
  ## Check it works
52
- card_show covers errors and fit. Before telling the user what it does, try its main action and a reload with
53
- {agent}/skills/browser-check/scripts/check.mjs:
54
- ```
55
- node <that check.mjs> {page}/built/apps/<name>/index.html "eval:(()=>{const i=document.querySelector('#new'); i.value='Test-1';
56
- i.dispatchEvent(new Event('input',{bubbles:true})); i.form.requestSubmit(); return 'ok'})()" wait:800 \
57
- "eval:document.body.innerText.includes('Test-1')"
58
- ```
59
- Set values with eval (`type:` adds to what a field already holds). Run it again to see what a reload keeps.
60
- Your test writes the user's real data: note it first (cat {data}/app/<name>.json) and put it back afterwards
61
- (PUT, above). Say only what you saw working.
64
+ card_show covers errors and fit. Then, before telling the user what it does, use it on their page as they would:
65
+ load the test-your-app skill. Say only what you saw working.
62
66
 
63
67
  ## More
64
68
  - Calculations (totals, streaks, dates) belong in the page's code, never worked out by you and typed in.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: test-your-app
3
+ description: Test an app you built by using it on the user's page as they would - its main tasks start to finish, a look at it, a reload. Use after card_show, before you tell the user what the app does or that it works.
4
+ ---
5
+ # Testing an app you built
6
+
7
+ Load the interact-with-page skill first: this one only adds what testing needs.
8
+
9
+ 1. Save the app's data: `cat {data}/app/<name>.json > /tmp/<name>.saved.json` (no file: it has saved nothing yet).
10
+ The test writes the user's real data.
11
+ 2. List the user's main tasks, from the app's about and your plan: add, change, remove, filter, the one idea that
12
+ makes it worth having.
13
+ 3. Do each task start to finish through the interface, with the helpers, one or a few steps per page_run, each
14
+ ending with a read. Never call the app's own functions or set its state yourself: that tests your code, not what
15
+ the user gets. After each step: did what you expected appear? Any errors in the answer?
16
+ 4. Look once: screen_see, and check what reading can't: text cut off or overlapping, things outside their box,
17
+ colours off the palette, a chart that doesn't match its numbers, empty space. If the user doesn't share their
18
+ screen, take a browser-check screenshot of {page}/built/apps/<name>/index.html (--shot) and read it.
19
+ 5. Reload: `location.reload()` in one page_run, then a read in the next: what was kept?
20
+ 6. Fix what you found (edit the part), show it again, and redo the tasks that touched it.
21
+ 7. Put the data back: `curl -X PUT --data @/tmp/<name>.saved.json {page}/app/data/<name>` (nothing saved: `--data '{}'`).
22
+ 8. Tell the user what you did and what you saw ("added and ticked a habit, reloaded: kept; long names were cut off,
23
+ now they wrap"), not just "it works".
@@ -9,6 +9,8 @@
9
9
  // The shelf: a dot per board (yours is bigger). Hover it and the dots open into thumbnails, with + for a new board;
10
10
  // click one to go there. It stays clear of the chat panel: more thumbnails than fit scroll between ‹ and › (grey at
11
11
  // the ends; a new board scrolls to the end). Drag a card by its label onto a thumbnail to move it to that board, where it was ({type: "card_board"}).
12
+ // Zoom: each board keeps its own (in this browser), as CSS zoom on #top, so places stay in main-space px; the control
13
+ // at the bottom right (−, %, +, fit), or Ctrl/⌘ + wheel (a trackpad pinch). See setZoom below.
12
14
  // page messages board_view {board}: the board this page shows; board_add
13
15
  // GET /board {boards, current} event "board" {boards, added?, by?}
14
16
  const CSS = `
@@ -25,9 +27,6 @@ const CSS = `
25
27
  .boards-shelf .slot .x { display: none; position: absolute; top: 3px; right: 3px; width: 16px; height: 16px; border-radius: 50%;
26
28
  font-size: 9px; line-height: 16px; text-align: center; color: var(--danger); background: var(--glass-strong); box-shadow: var(--shadow); }
27
29
  .boards-shelf.open .slot:hover .x { display: block; }
28
- .boards-toast { position: fixed; left: 50%; top: 76px; transform: translateX(-50%); /* under the palette: clear of the shelf */ z-index: 27; display: flex; gap: 12px;
29
- align-items: center; padding: 7px 14px; border-radius: 10px; background: rgba(29, 29, 31, 0.88); color: #fff; font-size: 13px; }
30
- .boards-toast button { color: #9cc3f5; font-weight: 600; }
31
30
  .boards-shelf .dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(0, 0, 0, 0.22); transition: all 0.15s; }
32
31
  .boards-shelf .slot.on .dot { width: 11px; height: 11px; background: var(--text); }
33
32
  /* more boards than fit (fit(): clear of the chat panel): the thumbnails scroll inside .view, between ‹ and › */
@@ -64,7 +63,22 @@ const CSS = `
64
63
  .boards-shelf .slot.drop .mini { outline: 2px solid var(--accent); outline-offset: 1px; }
65
64
  .boards-shelf .add { display: none; width: 122px; height: 70px; border-radius: 6px; /* a thumbnail's size, border included */ border: 1px dashed rgba(0, 0, 0, 0.25);
66
65
  color: var(--muted); font-size: 13px; align-self: flex-start; margin-top: 3px; }
67
- .boards-shelf.open .add { display: block; } /* only in the open shelf: at rest it's just the dots */`;
66
+ .boards-shelf.open .add { display: block; } /* only in the open shelf: at rest it's just the dots */
67
+ /* zoom: CSS zoom on #top scales everything in the main space; its padding (room for the palette, rail, input) stays
68
+ the same on screen */
69
+ #top { padding: calc(var(--palette) / var(--zoom, 1)) calc((var(--rail) + 28px) / var(--zoom, 1))
70
+ calc(var(--bar) / var(--zoom, 1)) calc((var(--rail) + 28px) / var(--zoom, 1)); }
71
+ .boards-zoom { position: fixed; right: 16px; bottom: 24px; z-index: 25; display: flex; align-items: center; gap: 2px; padding: 3px;
72
+ border-radius: 12px; background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
73
+ border: 1px solid var(--edge); box-shadow: var(--shadow); font-size: 12px; color: var(--soft); }
74
+ .boards-zoom button { height: 26px; min-width: 26px; padding: 0 6px; border-radius: 8px; font-variant-numeric: tabular-nums; }
75
+ .boards-zoom button:hover { background: var(--hover); color: var(--text); }
76
+ .boards-zoom .pct { min-width: 46px; }
77
+ .boards-zoom .fit { font-size: 15px; }
78
+ /* the hand: drag the board to move around it; what is on it doesn't react meanwhile (apps' frames included) */
79
+ #top.panning { cursor: grab; }
80
+ #top.panning.grabbing { cursor: grabbing; }
81
+ #top.panning * { pointer-events: none !important; }`;
68
82
 
69
83
  export default async function (page) {
70
84
  document.head.insertAdjacentHTML("beforeend", `<style>${CSS}</style>`);
@@ -139,6 +153,129 @@ export default async function (page) {
139
153
  view.scrollLeft = s.offsetLeft - (view.clientWidth - s.offsetWidth) / 2;
140
154
  }
141
155
 
156
+ // ---- zoom: each board's own, kept in this browser (localStorage) ----
157
+ // CSS zoom on #top: places stay in main-space px (what cards, frames and marks store), and #top's scrollLeft/Top and
158
+ // clientWidth are in them too. Window px -> main px is (x - left) / zoom + scroll: the other extensions divide by
159
+ // #top's style.zoom where they turn the pointer into main px. Ctrl/⌘ + wheel (a trackpad pinch) zooms at the
160
+ // pointer; the control at the bottom right: −, the percentage (click: back to 100%), +, and fit (the whole board).
161
+ const MIN = 0.25, MAX = 2, KEY = "chalky.board-zoom";
162
+ const shot = new URLSearchParams(location.search).has("shot"); // the versions' headless view: always 100%
163
+ let zooms = {};
164
+ try { zooms = JSON.parse(localStorage.getItem(KEY)) || {}; } catch {}
165
+ const zoomOf = () => parseFloat(top.style.zoom) || 1;
166
+ function setZoom(z, at, save = true) { // at: the window point that stays put (else the middle of the view)
167
+ z = shot ? 1 : Math.min(MAX, Math.max(MIN, Math.round(z * 100) / 100));
168
+ const m = top.getBoundingClientRect(), z0 = zoomOf();
169
+ at ??= { x: m.left + m.width / 2, y: m.top + m.height / 2 };
170
+ const p = { x: (at.x - m.left) / z0 + top.scrollLeft, y: (at.y - m.top) / z0 + top.scrollTop }; // in main px
171
+ top.style.zoom = z === 1 ? "" : z;
172
+ top.style.setProperty("--zoom", z);
173
+ if (z !== z0) { top.scrollLeft = p.x - (at.x - m.left) / z; top.scrollTop = p.y - (at.y - m.top) / z; }
174
+ pct.textContent = `${Math.round(z * 100)}%`;
175
+ if (save) {
176
+ if (z === 1) delete zooms[current]; else zooms[current] = z;
177
+ try { localStorage.setItem(KEY, JSON.stringify(zooms)); } catch {}
178
+ }
179
+ }
180
+ function fitZoom() { // everything on this board in view
181
+ const els = [...container(current).children], z0 = zoomOf(), m = top.getBoundingClientRect();
182
+ if (!els.length) return setZoom(1);
183
+ const rs = els.map((el) => el.getBoundingClientRect()).filter((r) => r.width || r.height);
184
+ const main = (x, y) => ({ x: (x - m.left) / z0 + top.scrollLeft, y: (y - m.top) / z0 + top.scrollTop });
185
+ const a = main(Math.min(...rs.map((r) => r.left)), Math.min(...rs.map((r) => r.top)));
186
+ const b = main(Math.max(...rs.map((r) => r.right)), Math.max(...rs.map((r) => r.bottom)));
187
+ const s = getComputedStyle(document.documentElement), px = (v) => parseFloat(s.getPropertyValue(v)) || 0;
188
+ const side = px("--rail") + 28, w = innerWidth - 2 * side, h = innerHeight - px("--palette") - px("--bar"); // inside the padding
189
+ const z = Math.min(MAX, Math.max(MIN, Math.min(w / (b.x - a.x + 24), h / (b.y - a.y + 24), 1)));
190
+ setZoom(z);
191
+ top.scrollLeft = (a.x + b.x) / 2 - (m.width / 2) / z;
192
+ top.scrollTop = a.y - 12 - px("--palette") / z;
193
+ }
194
+ const zoomBar = Object.assign(document.createElement("div"), { className: "boards-zoom" });
195
+ zoomBar.dataset.name = "zoom";
196
+ const pct = button("pct", "100%", "Back to 100%", () => setZoom(1));
197
+ zoomBar.append(button("", "−", "Zoom out", () => setZoom(zoomOf() / 1.25)), pct,
198
+ button("", "+", "Zoom in", () => setZoom(zoomOf() * 1.25)), button("fit", "⤢", "Fit the whole board", fitZoom));
199
+ document.body.append(zoomBar);
200
+ const pinch = (e, at) => { // ctrl + wheel (a pinch on a trackpad sends that): zoom at the pointer, not the browser's
201
+ if (!e.ctrlKey && !e.metaKey) return;
202
+ e.preventDefault();
203
+ setZoom(zoomOf() * Math.exp(-e.deltaY * (e.deltaMode ? 0.05 : 0.01)), at);
204
+ };
205
+ // anywhere on the page, not only over the main space: over the messages panel (which lies over the board) a pinch
206
+ // would otherwise scroll the messages; captured, so nothing under the pointer scrolls with it
207
+ addEventListener("wheel", (e) => pinch(e, { x: e.clientX, y: e.clientY }), { passive: false, capture: true });
208
+ top.addEventListener("load", (e) => { // an app's frame (same site): its pinches too, its px turned into window px
209
+ const fr = e.target;
210
+ if (fr.localName !== "iframe") return;
211
+ try {
212
+ fr.contentWindow.addEventListener("wheel", (ev) => {
213
+ const r = fr.getBoundingClientRect(), k = zoomOf();
214
+ pinch(ev, { x: r.left + fr.clientLeft * k + ev.clientX * k, y: r.top + fr.clientTop * k + ev.clientY * k });
215
+ }, { passive: false });
216
+ } catch {} // another site's frame
217
+ }, true);
218
+
219
+ // ---- the hand: drag to move around the board (scrolls #top). The palette's hand tool (one tool at a time, Esc
220
+ // stops it); or hold Space (when not typing and no other tool is on); or drag with the middle button, any time.
221
+ // Two-finger scrolling on a trackpad moves around too (the browser's own scrolling).
222
+ const HAND = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V12"/>` +
223
+ `<path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0V12"/><path d="M14 5.5a1.5 1.5 0 0 1 3 0V12"/>` +
224
+ `<path d="M17 7.5a1.5 1.5 0 0 1 3 0V16a6 6 0 0 1-6 6h-2 .2a6 6 0 0 1-5-2.7l-.2-.3c-.3-.5-1.4-2.4-3.3-5.7a1.5 1.5 0 0 1 .5-2 1.9 1.9 0 0 1 2.3.3L8 13"/></svg>`;
225
+ const handButton = Object.assign(document.createElement("button"), { innerHTML: HAND,
226
+ title: "Hand: drag to move around the board (or hold Space, or drag with the middle button; Esc to stop)" });
227
+ handButton.dataset.name = "hand";
228
+ handButton.style.order = 99; // the palette's last tool, at the right end, whichever script adds its tools after this one
229
+ page.slot("status").append(handButton);
230
+ let hand = false, spaceHand = false, panning = null; // panning: {x, y, left, top} at the press
231
+ const panMode = () => top.classList.toggle("panning", hand || spaceHand || !!panning);
232
+ function setHand(v) {
233
+ hand = v;
234
+ handButton.classList.toggle("on", v);
235
+ panMode();
236
+ if (v) document.dispatchEvent(new CustomEvent("tool-on", { detail: "hand" })); // the other tools turn off
237
+ }
238
+ handButton.onclick = () => setHand(!hand);
239
+ document.addEventListener("tool-on", (e) => { if (hand && e.detail !== "hand") setHand(false); });
240
+ const typing = () => document.activeElement?.matches?.("input, textarea, select, [contenteditable=''], [contenteditable='true']") ||
241
+ document.activeElement?.localName === "iframe"; // an app has the keys
242
+ const otherTool = () => !!document.querySelector("#status button.on:not([data-name='show-marks']):not([data-name='hand'])");
243
+ addEventListener("keydown", (e) => {
244
+ if (e.key === "Escape") setHand(false);
245
+ if (e.code !== "Space") return;
246
+ if (spaceHand) return e.preventDefault(); // held: its repeats don't scroll the page either
247
+ if (typing() || otherTool() || e.ctrlKey || e.metaKey || e.altKey) return;
248
+ e.preventDefault(); // not a page scroll, nor a click on the focused button
249
+ spaceHand = true;
250
+ panMode();
251
+ }, true);
252
+ addEventListener("keyup", (e) => { if (e.code === "Space" && spaceHand) { spaceHand = false; panMode(); } }, true);
253
+ addEventListener("blur", () => { spaceHand = false; panMode(); });
254
+ top.addEventListener("pointerdown", (e) => {
255
+ if (!(e.button === 1 || (e.button === 0 && (hand || spaceHand)))) return;
256
+ e.preventDefault();
257
+ e.stopImmediatePropagation(); // not a click, a line or a frame on what is under it
258
+ panning = { x: e.clientX, y: e.clientY, left: top.scrollLeft, top: top.scrollTop, id: e.pointerId };
259
+ top.setPointerCapture(e.pointerId);
260
+ top.classList.add("grabbing");
261
+ panMode();
262
+ }, true);
263
+ top.addEventListener("pointermove", (e) => {
264
+ if (!panning || e.pointerId !== panning.id) return;
265
+ const z = zoomOf(); // window px moved -> main px scrolled, so the board stays under the pointer
266
+ top.scrollLeft = panning.left - (e.clientX - panning.x) / z;
267
+ top.scrollTop = panning.top - (e.clientY - panning.y) / z;
268
+ });
269
+ const stopPan = (e) => {
270
+ if (!panning || e.pointerId !== panning.id) return;
271
+ panning = null;
272
+ top.classList.remove("grabbing");
273
+ panMode();
274
+ };
275
+ top.addEventListener("pointerup", stopPan);
276
+ top.addEventListener("pointercancel", stopPan);
277
+ top.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); // the middle button doesn't open links
278
+
142
279
  const others = document.createElement("style"); // hides what is on the body for other boards (a note's pin)
143
280
  document.head.append(others);
144
281
  function show(n, tell = true) { // go to board n (tell: the server, which the first show got it from)
@@ -148,6 +285,7 @@ export default async function (page) {
148
285
  document.body.dataset.board = n;
149
286
  others.textContent = `body > [data-board]:not([data-board="${n}"]) { display: none !important; }`;
150
287
  for (const b of boards) container(b).classList.toggle("on", b === n);
288
+ setZoom(zooms[n] ?? 1, null, false); // each board keeps its own zoom (in this browser)
151
289
  if (tell) page.send({ type: "board_view", board: n });
152
290
  render();
153
291
  }
@@ -173,8 +311,7 @@ export default async function (page) {
173
311
  // ✕ on a thumbnail: the board leaves the shelf with a few seconds of Undo, and only then goes from the server
174
312
  // (board_remove: its things go with it). Leaving the page in those seconds removes it at once.
175
313
  let pending = null; // {n, was: the board shown when ✕ was clicked, timer}
176
- const toast = Object.assign(document.createElement("div"), { className: "boards-toast", hidden: true });
177
- document.body.append(toast);
314
+ let toast = null; // the notice with Undo (pageApi.notice)
178
315
  function removeBoard(n) {
179
316
  if (pending) commit();
180
317
  const i = boards.indexOf(n);
@@ -182,20 +319,20 @@ export default async function (page) {
182
319
  peek.hidden = true;
183
320
  if (current === n) show(boards.filter((b) => b !== n)[Math.max(0, i - 1)]); // the one before it (else after)
184
321
  else render();
185
- toast.replaceChildren(`Board ${n} removed`, button("", "Undo", "Bring it back", undo));
186
- toast.hidden = false;
322
+ toast?.close();
323
+ toast = page.notice([`Board ${n} removed`, button("go", "Undo", "Bring it back", undo)], () => pending && commit()); // ✕: removed now
187
324
  }
188
325
  function commit() {
189
326
  clearTimeout(pending.timer);
190
327
  page.send({ type: "board_remove", board: pending.n });
191
328
  pending = null;
192
- toast.hidden = true;
329
+ toast?.close();
193
330
  }
194
331
  function undo() {
195
332
  clearTimeout(pending.timer);
196
333
  const { n, was } = pending;
197
334
  pending = null;
198
- toast.hidden = true;
335
+ toast?.close();
199
336
  if (was === n) show(n);
200
337
  else render();
201
338
  }
@@ -3,6 +3,7 @@
3
3
  // A card is bare: only what the agent built, no frame (its label shows on hover: name, owner, ✕). It is
4
4
  // stacked in the main space, its content keeping its own size; or pinned at place {x, y, width?, height?}
5
5
  // (px from the main space's top-left, so it scrolls with the page), its root block then filling it.
6
+ // (a new card with no place gets one near the middle of the view: autoPlace)
6
7
  // What the user can do with the label (the handle, so clicks inside still work):
7
8
  // drag move it; a drop sends {type: "card_move", card, place} (unless draggable: false)
8
9
  // drag and hold still over another card (it lights up), release combine: it slides back and the page
@@ -287,11 +288,11 @@ export default async function (page) {
287
288
 
288
289
  page.on("card", (ev) => {
289
290
  if (ev.op === "render") {
290
- const old = cards[ev.card] || {};
291
+ const isNew = !cards[ev.card], old = cards[ev.card] || {};
291
292
  const { op, type, card, ...record } = ev;
292
293
  cards[ev.card] = { ...record, box: old.box, label: old.label, body: old.body, nodes: old.nodes, frame: old.frame,
293
294
  flipped: old.flipped, option: record.options ? old.option : 0 };
294
- draw(ev.card);
295
+ draw(ev.card).then(() => { if (isNew && !record.place) autoPlace(ev.card); });
295
296
  } else if (ev.op === "delete" && cards[ev.card]) {
296
297
  cards[ev.card].box?.remove();
297
298
  delete cards[ev.card];
@@ -306,6 +307,47 @@ export default async function (page) {
306
307
  }
307
308
  });
308
309
 
310
+ // A new card the agent gave no place: near the middle of what the user sees (between the panel, if open, and the
311
+ // right edge; below the palette, above the input bar), clear of everything else on the board. The nearest free
312
+ // spot to the middle, on a 40 px grid, keeping a 24 px gap. Only the tab in view, and only on the board shown
313
+ // (a card for another board stays stacked). Sent as card_place: saved, with no step of its own in the history.
314
+ const GAP = 24, STEP = 40, RINGS = 60;
315
+ async function autoPlace(id) {
316
+ const s = cards[id];
317
+ if (!s?.box || s.place || document.visibilityState !== "visible") return;
318
+ if ((Number(s.board) || 1) !== (Number(document.body.dataset.board) || 1)) return;
319
+ await new Promise(requestAnimationFrame); // its blocks drawn
320
+ if (s.place) return;
321
+ const box = s.box;
322
+ box.toggleAttribute("data-placed", true); // its size as a placed card, off the stack
323
+ Object.assign(box.style, { position: "absolute", margin: "0", left: "0px", top: "0px", visibility: "hidden" });
324
+ const z = parseFloat(area.style.zoom) || 1, w = box.offsetWidth, h = box.offsetHeight;
325
+ const m = area.getBoundingClientRect();
326
+ const main = (x, y) => ({ x: (x - m.left) / z + area.scrollLeft, y: (y - m.top) / z + area.scrollTop });
327
+ const css = getComputedStyle(document.documentElement), px = (v) => parseFloat(css.getPropertyValue(v)) || 0;
328
+ const panel = document.getElementById("panel"), open = ["messages", "versions"].includes(document.body.dataset.panel);
329
+ const side = px("--rail") + 28;
330
+ const a = main(Math.max(side, open && panel ? panel.getBoundingClientRect().right : 0), px("--palette"));
331
+ const b = main(innerWidth - side, innerHeight - px("--bar"));
332
+ const cx = (a.x + b.x) / 2 - w / 2, cy = (a.y + b.y) / 2 - h / 2;
333
+ const others = [...(box.parentElement?.children ?? [])].filter((el) => el !== box).map((el) => el.getBoundingClientRect())
334
+ .filter((r) => r.width && r.height).map((r) => ({ ...main(r.left, r.top), w: r.width / z, h: r.height / z }));
335
+ const free = (x, y) => others.every((o) => x + w + GAP <= o.x || o.x + o.w + GAP <= x || y + h + GAP <= o.y || o.y + o.h + GAP <= y);
336
+ const spots = [];
337
+ for (let i = -RINGS; i <= RINGS; i++) for (let j = -RINGS; j <= RINGS; j++) {
338
+ const x = Math.round(cx + i * STEP), y = Math.round(cy + j * STEP);
339
+ if (x >= 0 && y >= 0) spots.push({ x, y, d: (i * i + j * j) });
340
+ }
341
+ spots.sort((p, q) => p.d - q.d);
342
+ const spot = spots.find((p) => free(p.x, p.y)) ?? { x: Math.max(0, Math.round(cx)), y: Math.max(0, Math.round(cy)) };
343
+ // an app keeps its width: a placed box with none shrinks to the room left of the window's edge, and in a narrower
344
+ // window (the card_show check's browser) an app far right would be 0px wide
345
+ s.place = s.app ? { x: spot.x, y: spot.y, width: w } : { x: spot.x, y: spot.y };
346
+ Object.assign(box.style, { left: `${spot.x}px`, top: `${spot.y}px`, visibility: "" });
347
+ if (s.app) { box.style.width = `${w}px`; s.frame && (s.frame.style.width = "100%"); }
348
+ page.send({ type: "card_place", card: id, place: s.place });
349
+ }
350
+
309
351
  function pinStacked() { // pin each stacked card where it is now, so taking one out of the stack moves none
310
352
  const stacked = Object.entries(cards).filter(([, s]) => !s.place && s.box);
311
353
  for (const [, s] of stacked) s.place = { x: s.box.offsetLeft, y: s.box.offsetTop }; // read all, then move
@@ -325,6 +367,7 @@ export default async function (page) {
325
367
  const handle = e.currentTarget, box = s.box;
326
368
  handle.setPointerCapture(e.pointerId); // keep getting moves over iframes and other cards
327
369
  let place = null, held = false, x0 = 0, y0 = 0;
370
+ const z = parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js): window px moved -> main px
328
371
  let under = null, armed = null, wait = null, sx = 0, sy = 0; // the card under the pointer; armed: lit, ready to combine
329
372
  const hold = setTimeout(() => { held = true; page.send({ type: "card_pin", card: id, pinned: !s.pinned }); }, HOLD_MS);
330
373
  handle.onpointermove = (ev) => {
@@ -332,7 +375,7 @@ export default async function (page) {
332
375
  clearTimeout(hold);
333
376
  if (s.pinned || s.draggable === false) return;
334
377
  if (!place) { pinStacked(); x0 = box.offsetLeft; y0 = box.offsetTop; box.style.zIndex = 4; } // offsets in the main space; above the rest while dragged
335
- place = { ...s.place, x: Math.round(x0 + ev.clientX - e.clientX), y: Math.round(y0 + ev.clientY - e.clientY) };
378
+ place = { ...s.place, x: Math.round(x0 + (ev.clientX - e.clientX) / z), y: Math.round(y0 + (ev.clientY - e.clientY) / z) };
336
379
  box.toggleAttribute("data-placed", true);
337
380
  Object.assign(box.style, { position: "absolute", margin: "0", left: `${place.x}px`, top: `${place.y}px` });
338
381
  const now = cardAt(ev.clientX, ev.clientY, box);
@@ -366,12 +409,12 @@ export default async function (page) {
366
409
  if (s.pinned) return;
367
410
  pinStacked();
368
411
  const handle = e.currentTarget, box = s.box;
369
- const x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
412
+ const x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight, z = parseFloat(area.style.zoom) || 1;
370
413
  handle.setPointerCapture(e.pointerId);
371
414
  let place = null;
372
415
  handle.onpointermove = (ev) => {
373
- place = { x, y, width: Math.min(area.clientWidth - x, Math.max(40, Math.round(w0 + ev.clientX - e.clientX))),
374
- height: Math.max(30, Math.round(h0 + ev.clientY - e.clientY)) };
416
+ place = { x, y, width: Math.min(area.clientWidth - x, Math.max(40, Math.round(w0 + (ev.clientX - e.clientX) / z))),
417
+ height: Math.max(30, Math.round(h0 + (ev.clientY - e.clientY) / z)) };
375
418
  box.toggleAttribute("data-placed", true);
376
419
  Object.assign(box.style, { position: "absolute", margin: "0", left: `${x}px`, top: `${y}px`,
377
420
  width: `${place.width}px`, height: `${place.height}px` });
@@ -5,10 +5,10 @@
5
5
  // skill's headless browser and says what it found: errors, and whether the app fits its card. The cards are shared by every agent: one Cards object on
6
6
  // the host, made by whichever agent loads first.
7
7
  // One door: every change goes through change(): it saves, tells every open page, and calls every watcher with the
8
- // card, who did it and the words for it ("you moved s1"; none: part of a bigger change). restore() puts back a
9
- // saved state (not a change: no watchers). Watchers, registered from session_start:
8
+ // card, who did it ("user" or the agent) and the words for it, without who ("moved s1"; none: part of a bigger
9
+ // change). restore() puts back a saved state (not a change: no watchers). Watchers, registered from session_start:
10
10
  // host.api.card.watch(name, fn(card, by, what)) name: the extension's, so its reload replaces its own
11
- // (history: a step per change with words; frame: a frame is done when its card appears).
11
+ // (history: a step per change with words; frame: a frame is done when its card appears; version: a version's label).
12
12
  // built/card.json each card but its data (versioned) data/card.json each card's data (what users typed)
13
13
  // A card: {owner, blocks | app, data, place, board, about, pinned, options, made_from, instruction, draggable,
14
14
  // resizable, height (an app's, when nothing else sizes it), kinds (each block's about.version, for upgrades)}.
@@ -16,7 +16,7 @@
16
16
  // GET /card/all every card GET /card/about block name -> its about block
17
17
  // GET /card/blocks block name -> mtime GET /card/blocks/<Name>.js, /card/node_modules/{marked,dompurify}/...
18
18
  // GET /card/lib/... libraries the agents added for blocks (lib/ here: ES-module builds)
19
- // page messages card_set, card_prop, card_move, card_board, card_pin, card_keep, card_close, card_action
19
+ // page messages card_set, card_prop, card_move, card_place, card_board, card_pin, card_keep, card_close, card_action
20
20
  // page event "card" {op: "render", card, ...record} | {op: "delete", card} | {op: "reset", cards}
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
@@ -200,7 +200,7 @@ export default function (pi: ExtensionAPI) {
200
200
  const gone = ids.map((id) => ({ owner: cards.cards[id].owner, what: `card ${id}` }));
201
201
  ids.forEach((id, i) => {
202
202
  cards.copyIntoUsers(id); // cards elsewhere reading from it keep its values
203
- cards.change(id, null, "user", i === ids.length - 1 ? `you removed board ${n} (${ids.join(", ")})` : undefined);
203
+ cards.change(id, null, "user", i === ids.length - 1 ? `removed board ${n} (${ids.join(", ")})` : undefined);
204
204
  });
205
205
  return gone;
206
206
  }));
@@ -268,13 +268,13 @@ export default function (pi: ExtensionAPI) {
268
268
  ["instruction", a.instruction ?? old.instruction], ["options", options.length ? options : null]] as const) if (v) record[k] = v;
269
269
  // its board (board/): the one asked for, a new one, where it already is, or the one the user is on
270
270
  if (boards) record.board = a.board === "new" ? boards.add() : a.board ?? old.board ?? boards.current();
271
- cards.change(a.card, record, agent, `${agent} showed ${a.card}` + (options.length ? ` (${options.length} options)` : ""));
271
+ cards.change(a.card, record, agent, `showed ${a.card}` + (options.length ? ` (${options.length} options)` : ""));
272
272
  const where = boards ? `on board ${record.board}${a.board === "new" ? " (a new board)" : ""}` : "on screen";
273
273
  if (options.length) return result(`card '${a.card}' shows ${options.length} options ${where}; the user flips through them and keeps one`);
274
274
  if (!main.app) return result(`card '${a.card}' is ${where}`);
275
275
  return result(`card '${a.card}' is ${where}, showing ${host.built}/${main.app} (at http://127.0.0.1:${host.port}/built/${main.app}); `
276
- + await checkOnBoard(a.card) + ". Errors and fit are all this checked: now try its main action and a reload (the "
277
- + "app README's check) before you tell the user what it does.");
276
+ + await checkOnBoard(a.card) + ". Errors and fit are all this checked: now use it as the user would (load the "
277
+ + "test-your-app skill) before you tell the user what it does.");
278
278
  },
279
279
  });
280
280
 
@@ -296,11 +296,11 @@ export default function (pi: ExtensionAPI) {
296
296
  b[a.path] = a.value;
297
297
  const errors = problems(r.blocks, r.made_from);
298
298
  if (errors.length) return result("error: " + errors.join("; "));
299
- cards.change(a.card, r, agent, `${agent} set ${a.path} of ${a.block} in ${a.card}`);
299
+ cards.change(a.card, r, agent, `set ${a.path} of ${a.block} in ${a.card}`);
300
300
  return result("updated");
301
301
  }
302
302
  setPath(r.data, a.path, a.value);
303
- cards.change(a.card, r, agent, `${agent} changed ${a.path} in ${a.card}`);
303
+ cards.change(a.card, r, agent, `changed ${a.path} in ${a.card}`);
304
304
  return result("updated");
305
305
  },
306
306
  });
@@ -313,7 +313,7 @@ export default function (pi: ExtensionAPI) {
313
313
  const locked = cards.locked(a.card);
314
314
  if (locked) return result(locked);
315
315
  const users = cards.copyIntoUsers(a.card), agent = pi.getSessionName() ?? "agent";
316
- cards.change(a.card, null, agent, `${agent} removed ${a.card}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
316
+ cards.change(a.card, null, agent, `removed ${a.card}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
317
317
  return result("removed" + (users.length ? `; ${users.join(", ")} read from it, so they got a copy of its data` : ""));
318
318
  },
319
319
  });
@@ -339,30 +339,33 @@ export default function (pi: ExtensionAPI) {
339
339
  if (!r || !type?.startsWith("card_")) return;
340
340
  if (type === "card_close") {
341
341
  const users = cards.copyIntoUsers(id);
342
- cards.change(id, null, "user", `you closed ${id}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
342
+ cards.change(id, null, "user", `closed ${id}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
343
343
  }
344
344
  else if (type === "card_move") {
345
345
  if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
346
- r.place = msg.place; cards.change(id, r, "user", `you moved ${id}`);
346
+ r.place = msg.place; cards.change(id, r, "user", `moved ${id}`);
347
+ } else if (type === "card_place") { // the page put a new card with no place near the middle of the view: not a move
348
+ if (r.place || r.pinned) return; // the user saw it appear there, so no step of its own (it is part of the showing)
349
+ r.place = msg.place; cards.change(id, r);
347
350
  } else if (type === "card_board" && host.api.board?.has(msg.board)) { // dropped on another board's thumbnail
348
351
  if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
349
- r.board = msg.board; r.place = msg.place ?? null; cards.change(id, r, "user", `you moved ${id} to board ${msg.board}`);
350
- } else if (type === "card_set") { setPath(r.data, msg.path ?? "/", msg.value); cards.change(id, r, "user", `you changed ${msg.path} in ${id}`); }
352
+ r.board = msg.board; r.place = msg.place ?? null; cards.change(id, r, "user", `moved ${id} to board ${msg.board}`);
353
+ } else if (type === "card_set") { setPath(r.data, msg.path ?? "/", msg.value); cards.change(id, r, "user", `changed ${msg.path} in ${id}`); }
351
354
  else if (type === "card_prop") {
352
355
  for (const b of r.blocks ?? []) if (b.id === msg.block && !STRUCTURE.includes(msg.prop)) b[msg.prop] = msg.value;
353
- cards.change(id, r, "user", `you set ${msg.prop} of ${msg.block} in ${id}`);
354
- } else if (type === "card_pin") { r.pinned = !!msg.pinned; cards.change(id, r, "user", `you ${r.pinned ? "pinned" : "unpinned"} ${id}`); }
356
+ cards.change(id, r, "user", `set ${msg.prop} of ${msg.block} in ${id}`);
357
+ } else if (type === "card_pin") { r.pinned = !!msg.pinned; cards.change(id, r, "user", `${r.pinned ? "pinned" : "unpinned"} ${id}`); }
355
358
  else if (type === "card_keep" && r.options) {
356
359
  const o = r.options[Number(msg.index ?? 0)];
357
360
  delete r.options;
358
361
  if (o) {
359
362
  delete r.blocks; delete r.app;
360
363
  Object.assign(r, o.app ? { app: o.app } : { blocks: o.blocks }, { data: o.data });
361
- cards.change(id, r, "user", `you kept ${o.label} of ${id}`);
364
+ cards.change(id, r, "user", `kept ${o.label} of ${id}`);
362
365
  }
363
366
  } else if (type === "card_action") {
364
367
  r.data = msg.data ?? r.data;
365
- cards.change(id, r, "user", `you used ${msg.name} in ${id}`);
368
+ cards.change(id, r, "user", `used ${msg.name} in ${id}`);
366
369
  host.send("user", r.owner, `[card action] ${msg.name} on card '${id}' (${msg.block === "app" ? "its app" : `block ${msg.block}`})\n`
367
370
  + `context: ${JSON.stringify(msg.context ?? {})}\ndata: ${JSON.stringify(r.data)}`);
368
371
  }
@@ -29,7 +29,7 @@ You name the thing; the page draws it by hand in your colour, and keeps it on th
29
29
  page (the mark goes on just those characters), element a CSS selector or an element's id or data-name (also inside
30
30
  an app's frame), mark one of the marks (theirs or yours: m7). Example: {"card": "shop", "text": "12.50"}.
31
31
  - The page's own controls can be marked too, by name: {element: "pen"} (the marker), "text", "eraser", "show-marks"
32
- (the eye), "note" (the note pencil), "frames", "files", "dictate" (the mic), "tools" (the whole palette, top centre),
32
+ (the eye), "note" (the speech bubble), "frames", "files", "dictate" (the mic), "tools" (the whole palette, top centre),
33
33
  "rail" (left edge), "messages", "versions", "message-line" (bottom), "boards" (the shelf above it), "new-board". Such
34
34
  marks stay fixed to the window. Good for a tour: ring "pen" with "draw here".
35
35
  - at: {x, y, width, height} in window px, as screen_see's picture shows it: a last resort for something with no name