@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 +8 -2
- package/agent/SYSTEM.md +2 -1
- package/agent/extensions/app/README.md +15 -11
- package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
- package/agent/extensions/board/browser.js +147 -10
- package/agent/extensions/card/browser.js +49 -6
- package/agent/extensions/card/index.ts +22 -19
- package/agent/extensions/doodle/README.md +1 -1
- package/agent/extensions/doodle/browser.js +20 -18
- package/agent/extensions/file/browser.js +46 -7
- package/agent/extensions/file/index.ts +3 -2
- package/agent/extensions/frame/README.md +2 -2
- package/agent/extensions/frame/browser.js +5 -3
- package/agent/extensions/frame/index.ts +16 -7
- package/agent/extensions/history/index.ts +3 -1
- package/agent/extensions/note/README.md +3 -1
- package/agent/extensions/note/browser.js +52 -23
- package/agent/extensions/note/guide.md +2 -1
- package/agent/extensions/note/index.ts +36 -13
- package/agent/extensions/page/browser.js +453 -0
- package/agent/extensions/page/index.ts +70 -1
- package/agent/extensions/page/page.html +48 -1
- package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
- package/agent/extensions/screen/browser.js +8 -21
- package/agent/extensions/screen/index.ts +7 -5
- package/agent/extensions/version/browser.js +12 -3
- package/agent/extensions/version/index.ts +73 -21
- package/agent/skills/design/SKILL.md +1 -1
- package/agent/skills/show-me/SKILL.md +66 -0
- package/package.json +2 -1
- package/server.ts +169 -7
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)
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
toast
|
|
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
|
|
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
|
|
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 ("
|
|
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 ? `
|
|
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,
|
|
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
|
|
277
|
-
+ "app
|
|
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,
|
|
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,
|
|
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,
|
|
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", `
|
|
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", `
|
|
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", `
|
|
350
|
-
} else if (type === "card_set") { setPath(r.data, msg.path ?? "/", msg.value); cards.change(id, r, "user", `
|
|
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", `
|
|
354
|
-
} else if (type === "card_pin") { r.pinned = !!msg.pinned; cards.change(id, r, "user",
|
|
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", `
|
|
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", `
|
|
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
|
|
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
|