@vibes.diy/prompts 14.3.82 → 14.3.84

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.
@@ -0,0 +1,964 @@
1
+ # Blockly — block editors whose blocks write real code
2
+
3
+ When someone asks for "Scratch-like" blocks, a drag-and-drop block editor, a
4
+ visual programming playground, a page builder for a kid, or any surface where
5
+ people snap pieces together instead of typing code, this is the recipe. It is
6
+ equally the recipe when the words are business words: a rules engine the
7
+ analyst edits (credit scoring, fraud flags, approval thresholds, compliance
8
+ checks, discount rules, eligibility, routing), a CRM or ERP automation ("when
9
+ an order arrives, if it matches, tag it and send it to billing"), a low-code
10
+ form or screen builder, a step sequence for a robot or a conveyor, a test
11
+ script built from steps and expectations. The person who owns the policy
12
+ edits it; the connection checks keep every stack one the app can run. Blockly
13
+ is a library for building a block editor that is yours: your app decides which
14
+ blocks exist, which ones snap into which slots, and what a finished stack
15
+ compiles to — a web page, a JSON rule, JavaScript, a list of steps. The person
16
+ drags; the generator writes; the app runs the result.
17
+
18
+ The worked example is `jchris/block-builder`, a page builder a kid can use:
19
+ structure blocks, style blocks and action blocks compile to an HTML document
20
+ shown live in an iframe, with a Code Peek toggle that shows the real HTML, CSS
21
+ and JavaScript the blocks produced. The two modules below distil it — one that
22
+ compiles blocks to a page, one that compiles blocks to a rule — and the sections
23
+ after them cover each decision the example makes.
24
+
25
+ ## Importing Blockly — one pinned bundle
26
+
27
+ **Import Blockly from one version-pinned esm.sh URL with `?bundle`:**
28
+
29
+ ```js
30
+ import * as Blockly from "https://esm.sh/blockly@13.3.0?bundle";
31
+ ```
32
+
33
+ That one module carries the core, the standard blocks (logic, loops, math,
34
+ text, lists, variables) and the English messages, all bound to one `Blockly`
35
+ object. The `?bundle` is load-bearing: the package is a set of compiled files
36
+ that find each other through a Node-style require of the core, and a per-file
37
+ CDN build hands each file its own copy of that core, so the standard blocks
38
+ register into a `Blockly.Blocks` nobody renders (measured 2026-10-07: the
39
+ plain index import throws at load on every version, and jsDelivr's `+esm`
40
+ loads quietly with an empty block registry). The bundle keeps them together.
41
+
42
+ The JavaScript generator for the standard blocks is a second pinned bundle:
43
+
44
+ ```js
45
+ import { javascriptGenerator, Order } from "https://esm.sh/blockly@13.3.0/javascript?bundle";
46
+ ```
47
+
48
+ Another language's messages are a plain object, so a locale loads from its own
49
+ subpath and `Blockly.setLocale` switches the standard blocks' labels:
50
+ `import * as Fr from "https://esm.sh/blockly@13.3.0/msg/fr"; Blockly.setLocale(Fr);`.
51
+ Your own blocks carry their labels in their definitions, so translate those
52
+ in your own data.
53
+
54
+ **Import Blockly in exactly one module and re-export it** (`export { Blockly }`
55
+ from `lib/blocks.js`, imported by `App.jsx`). Blocks register on the object the
56
+ module imported, and a second import site is a second registry.
57
+
58
+ Plugins such as `@blockly/field-colour` are compiled against their own copy of
59
+ the core, so a colour choice is a `field_dropdown` of named swatches (`["plum",
60
+ "#8b5cf6"]`) — which also gives a kid a readable name per colour.
61
+
62
+ ## Project shape: a React shell around a blocks module
63
+
64
+ ```
65
+ App.jsx the React shell: project list, the workspace host, the preview, Code Peek
66
+ lib/blocks.js every Blockly import, the block definitions, the toolbox, the generator, STARTER
67
+ access.js who may edit which project
68
+ seed.json two or three finished projects so the first visit has something to click
69
+ ```
70
+
71
+ Relative imports include the extension (`./lib/blocks.js`). `App.jsx` is the
72
+ only client file with `export default`; `lib/blocks.js` uses named exports and
73
+ holds the Blockly definitions beside plain generator functions, so the
74
+ evaluator a rules module exports runs as well from `backend.js` as from the
75
+ browser.
76
+
77
+ ## Complete starter — a page builder
78
+
79
+ The blocks module. Two statement families, told apart by connection checks: a
80
+ `piece` block holds other pieces, a piece's "looks" slot holds `look` blocks,
81
+ and a button's "when pressed" slot holds `action` blocks. Blockly refuses every
82
+ other snap, which is the whole safety story: the person can only build pages
83
+ the generator knows how to write.
84
+
85
+ ### `lib/blocks.js`
86
+ ```js
87
+ import * as Blockly from "https://esm.sh/blockly@13.3.0?bundle";
88
+
89
+ export { Blockly };
90
+
91
+ const SWATCHES = [
92
+ ["plum", "#8b5cf6"], ["coral", "#f97316"], ["sunshine", "#facc15"],
93
+ ["leaf", "#22c55e"], ["sky", "#3b82f6"], ["ink", "#111827"], ["paper", "#ffffff"],
94
+ ];
95
+
96
+ Blockly.defineBlocksWithJsonArray([
97
+ {
98
+ type: "piece_heading",
99
+ message0: "heading %1 named %2",
100
+ args0: [
101
+ { type: "field_input", name: "TEXT", text: "Hello there" },
102
+ { type: "field_input", name: "NAME", text: "" },
103
+ ],
104
+ message1: "looks %1",
105
+ args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
106
+ previousStatement: "piece",
107
+ nextStatement: "piece",
108
+ colour: 20,
109
+ tooltip: "A big line of text.",
110
+ },
111
+ {
112
+ type: "piece_text",
113
+ message0: "paragraph %1 named %2",
114
+ args0: [
115
+ { type: "field_input", name: "TEXT", text: "Write something" },
116
+ { type: "field_input", name: "NAME", text: "" },
117
+ ],
118
+ message1: "looks %1",
119
+ args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
120
+ previousStatement: "piece",
121
+ nextStatement: "piece",
122
+ colour: 20,
123
+ },
124
+ {
125
+ type: "piece_button",
126
+ message0: "button %1 named %2",
127
+ args0: [
128
+ { type: "field_input", name: "TEXT", text: "Press me" },
129
+ { type: "field_input", name: "NAME", text: "" },
130
+ ],
131
+ message1: "looks %1",
132
+ args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
133
+ message2: "when pressed %1",
134
+ args2: [{ type: "input_statement", name: "PRESSED", check: "action" }],
135
+ previousStatement: "piece",
136
+ nextStatement: "piece",
137
+ colour: 20,
138
+ },
139
+ {
140
+ type: "piece_box",
141
+ message0: "box named %1",
142
+ args0: [{ type: "field_input", name: "NAME", text: "" }],
143
+ message1: "looks %1",
144
+ args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
145
+ message2: "inside %1",
146
+ args2: [{ type: "input_statement", name: "INSIDE", check: "piece" }],
147
+ previousStatement: "piece",
148
+ nextStatement: "piece",
149
+ colour: 340,
150
+ },
151
+ {
152
+ type: "look_background",
153
+ message0: "background %1",
154
+ args0: [{ type: "field_dropdown", name: "COLOR", options: SWATCHES }],
155
+ previousStatement: "look",
156
+ nextStatement: "look",
157
+ colour: 210,
158
+ },
159
+ {
160
+ type: "look_text_color",
161
+ message0: "text color %1",
162
+ args0: [{ type: "field_dropdown", name: "COLOR", options: SWATCHES }],
163
+ previousStatement: "look",
164
+ nextStatement: "look",
165
+ colour: 210,
166
+ },
167
+ {
168
+ type: "look_size",
169
+ message0: "size %1",
170
+ args0: [{ type: "field_dropdown", name: "SIZE", options: [["small", "14px"], ["medium", "20px"], ["large", "32px"], ["huge", "56px"]] }],
171
+ previousStatement: "look",
172
+ nextStatement: "look",
173
+ colour: 210,
174
+ },
175
+ {
176
+ type: "look_rounded",
177
+ message0: "rounded corners",
178
+ previousStatement: "look",
179
+ nextStatement: "look",
180
+ colour: 210,
181
+ },
182
+ {
183
+ type: "action_change_text",
184
+ message0: "change text of %1 to %2",
185
+ args0: [
186
+ { type: "field_input", name: "TARGET", text: "greeting" },
187
+ { type: "field_input", name: "TEXT", text: "You pressed it!" },
188
+ ],
189
+ previousStatement: "action",
190
+ nextStatement: "action",
191
+ colour: 120,
192
+ },
193
+ {
194
+ type: "action_toggle",
195
+ message0: "show or hide %1",
196
+ args0: [{ type: "field_input", name: "TARGET", text: "secret" }],
197
+ previousStatement: "action",
198
+ nextStatement: "action",
199
+ colour: 120,
200
+ },
201
+ ]);
202
+
203
+ const category = (name, colour, types) => ({
204
+ kind: "category",
205
+ name,
206
+ colour,
207
+ contents: types.map((type) => ({ kind: "block", type })),
208
+ });
209
+
210
+ export const toolbox = {
211
+ kind: "categoryToolbox",
212
+ contents: [
213
+ category("Pieces", "20", ["piece_heading", "piece_text", "piece_button", "piece_box"]),
214
+ category("Looks", "210", ["look_background", "look_text_color", "look_size", "look_rounded"]),
215
+ category("Actions", "120", ["action_change_text", "action_toggle"]),
216
+ ],
217
+ };
218
+
219
+ // A generator whose output is a web page. `forBlock` holds one function per block type;
220
+ // `scrub_` is what chains a block to the block under it.
221
+ const html = new Blockly.CodeGenerator("PageHtml");
222
+ html.scrub_ = function (block, code, thisOnly) {
223
+ const next = block.nextConnection && block.nextConnection.targetBlock();
224
+ return next && !thisOnly ? code + this.blockToCode(next) : code;
225
+ };
226
+
227
+ const escapeText = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
228
+ const slug = (s) => String(s ?? "").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
229
+
230
+ // Looks compile to CSS declarations and pieces compile to tags. Each piece collects its own
231
+ // looks and actions into the shared `page` object, so one pass yields markup, styles and script.
232
+ function piece(block, tag, inner) {
233
+ const page = html.page;
234
+ page.count += 1;
235
+ const id = slug(block.getFieldValue("NAME")) || `piece-${page.count}`;
236
+ const looks = html.statementToCode(block, "LOOKS");
237
+ if (looks) page.css.push(`#${id} {\n${looks}}`);
238
+ const pressed = block.getInput("PRESSED") ? html.statementToCode(block, "PRESSED") : "";
239
+ if (pressed) page.js.push(`document.getElementById("${id}").addEventListener("click", () => {\n${pressed}});`);
240
+ return `<${tag} id="${id}">${inner}</${tag}>\n`;
241
+ }
242
+
243
+ html.forBlock["piece_heading"] = (b) => piece(b, "h1", escapeText(b.getFieldValue("TEXT")));
244
+ html.forBlock["piece_text"] = (b) => piece(b, "p", escapeText(b.getFieldValue("TEXT")));
245
+ html.forBlock["piece_button"] = (b) => piece(b, "button", escapeText(b.getFieldValue("TEXT")));
246
+ html.forBlock["piece_box"] = (b, gen) => piece(b, "section", "\n" + gen.statementToCode(b, "INSIDE"));
247
+ html.forBlock["look_background"] = (b) => ` background: ${b.getFieldValue("COLOR")};\n`;
248
+ html.forBlock["look_text_color"] = (b) => ` color: ${b.getFieldValue("COLOR")};\n`;
249
+ html.forBlock["look_size"] = (b) => ` font-size: ${b.getFieldValue("SIZE")};\n`;
250
+ html.forBlock["look_rounded"] = () => ` border-radius: 16px;\n padding: 12px;\n`;
251
+ html.forBlock["action_change_text"] = (b) =>
252
+ ` document.getElementById(${JSON.stringify(slug(b.getFieldValue("TARGET")))}).textContent = ${JSON.stringify(b.getFieldValue("TEXT"))};\n`;
253
+ html.forBlock["action_toggle"] = (b) =>
254
+ ` document.getElementById(${JSON.stringify(slug(b.getFieldValue("TARGET")))}).toggleAttribute("hidden");\n`;
255
+
256
+ /** The whole page as one HTML document — what the preview iframe and the download both show. */
257
+ export function compile(workspace) {
258
+ html.page = { count: 0, css: [], js: [] };
259
+ html.init(workspace);
260
+ let body = "";
261
+ for (const top of workspace.getTopBlocks(true)) {
262
+ if (top.type.startsWith("piece_")) body += html.blockToCode(top);
263
+ }
264
+ const { css, js } = html.page;
265
+ const style = ["body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; }", ...css].join("\n");
266
+ const script = js.join("\n");
267
+ const full = `<!doctype html>\n<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>\n${style}\n</style></head>\n<body>\n${body}<script>\n${script}\n</script></body></html>`;
268
+ return { html: body, css: style, js: script, full };
269
+ }
270
+
271
+ /** What an empty project opens on, so the first drag has something to snap to. */
272
+ export const STARTER = {
273
+ blocks: {
274
+ languageVersion: 0,
275
+ blocks: [
276
+ {
277
+ type: "piece_heading",
278
+ x: 24,
279
+ y: 24,
280
+ fields: { TEXT: "Hello there", NAME: "greeting" },
281
+ inputs: {
282
+ LOOKS: {
283
+ block: { type: "look_background", fields: { COLOR: "#8b5cf6" }, next: { block: { type: "look_text_color", fields: { COLOR: "#ffffff" } } } },
284
+ },
285
+ },
286
+ next: {
287
+ block: {
288
+ type: "piece_button",
289
+ fields: { TEXT: "Press me", NAME: "" },
290
+ inputs: { PRESSED: { block: { type: "action_change_text", fields: { TARGET: "greeting", TEXT: "You pressed it!" } } } },
291
+ },
292
+ },
293
+ },
294
+ ],
295
+ },
296
+ };
297
+ ```
298
+
299
+ The shell. The workspace is injected once, inside a `useEffect(() => {…}, [])`
300
+ that also disposes it; projects are documents whose `workspaceJson` is the
301
+ saved workspace; every change regenerates the page and, when this person may
302
+ edit the open project, schedules an autosave.
303
+
304
+ ### `App.jsx`
305
+ ```jsx
306
+ import React, { useEffect, useMemo, useRef, useState } from "react";
307
+ import { useFireproof } from "use-fireproof";
308
+ import { useVibe, useViewer } from "use-vibes";
309
+ import { Badge, Button, Card, Grid, Heading, Row, Segmented, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
310
+ import { Switch } from "@vibes.diy/pouf/interactive";
311
+ import { Blockly, compile, toolbox, STARTER } from "./lib/blocks.js";
312
+
313
+ const EMPTY = { html: "", css: "", js: "", full: "" };
314
+
315
+ export default function App() {
316
+ const { database, useLiveQuery } = useFireproof("pageBlocks");
317
+ const { me, can, ready } = useVibe("pageBlocks");
318
+ const { requestLogin } = useViewer();
319
+ const { docs } = useLiveQuery("type", { key: "project" });
320
+ const projects = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);
321
+
322
+ const [activeId, setActiveId] = useState(null);
323
+ const [code, setCode] = useState(EMPTY);
324
+ const [peek, setPeek] = useState(false);
325
+ const [pane, setPane] = useState("blocks");
326
+ const [saveState, setSaveState] = useState("saved");
327
+
328
+ const hostRef = useRef(null);
329
+ const wsRef = useRef(null);
330
+ const saveTimer = useRef(null);
331
+ const activeRef = useRef(null);
332
+ const canEditRef = useRef(false);
333
+
334
+ const active = projects.find((p) => p._id === activeId) ?? projects[0] ?? null;
335
+ const canEdit = Boolean(active && ready && can.edit(active).ok);
336
+ const canMake = Boolean(ready && me && can.create({ type: "project", name: "x", creatorHandle: me.userHandle }).ok);
337
+ activeRef.current = active;
338
+ canEditRef.current = canEdit;
339
+
340
+ async function saveNow() {
341
+ saveTimer.current = null;
342
+ const ws = wsRef.current;
343
+ const target = activeRef.current;
344
+ if (!ws || !target || !canEditRef.current) return;
345
+ const workspaceJson = Blockly.serialization.workspaces.save(ws);
346
+ await database.put({ ...target, workspaceJson, updatedAt: Date.now() });
347
+ setSaveState("saved");
348
+ }
349
+
350
+ // Inject once. The host needs a real height (see below), and the cleanup disposes the workspace.
351
+ useEffect(() => {
352
+ const ws = Blockly.inject(hostRef.current, {
353
+ toolbox,
354
+ renderer: "zelos",
355
+ zoom: { controls: true, wheel: true, pinch: true, startScale: 0.85 },
356
+ move: { scrollbars: true, drag: true, wheel: false },
357
+ trashcan: true,
358
+ sounds: false,
359
+ });
360
+ wsRef.current = ws;
361
+ ws.addChangeListener((e) => {
362
+ if (e.isUiEvent) return;
363
+ setCode(compile(ws));
364
+ if (activeRef.current && canEditRef.current) {
365
+ setSaveState("saving");
366
+ clearTimeout(saveTimer.current);
367
+ saveTimer.current = setTimeout(saveNow, 800);
368
+ }
369
+ });
370
+ const ro = new ResizeObserver(() => Blockly.svgResize(ws));
371
+ ro.observe(hostRef.current);
372
+ return () => {
373
+ ro.disconnect();
374
+ clearTimeout(saveTimer.current);
375
+ ws.dispose();
376
+ wsRef.current = null;
377
+ };
378
+ }, []);
379
+
380
+ // Load the open project's blocks with events off, then regenerate once.
381
+ useEffect(() => {
382
+ const ws = wsRef.current;
383
+ if (!ws) return;
384
+ Blockly.Events.disable();
385
+ try {
386
+ ws.clear();
387
+ Blockly.serialization.workspaces.load(active?.workspaceJson || STARTER, ws);
388
+ } finally {
389
+ Blockly.Events.enable();
390
+ }
391
+ setCode(compile(ws));
392
+ setSaveState("saved");
393
+ }, [active?._id]);
394
+
395
+ async function pickProject(id) {
396
+ if (saveTimer.current) {
397
+ clearTimeout(saveTimer.current);
398
+ await saveNow();
399
+ }
400
+ setActiveId(id);
401
+ }
402
+
403
+ async function createProject(name, workspaceJson) {
404
+ if (!canMake) return;
405
+ if (saveTimer.current) {
406
+ clearTimeout(saveTimer.current);
407
+ await saveNow();
408
+ }
409
+ const doc = { _id: crypto.randomUUID(), type: "project", name, workspaceJson, creatorHandle: me.userHandle, updatedAt: Date.now() };
410
+ await database.put(doc);
411
+ setActiveId(doc._id);
412
+ }
413
+
414
+ function keepScratch() {
415
+ const ws = wsRef.current;
416
+ if (ws) createProject("My page", Blockly.serialization.workspaces.save(ws));
417
+ }
418
+
419
+ function download() {
420
+ const url = URL.createObjectURL(new Blob([code.full], { type: "text/html" }));
421
+ const a = document.createElement("a");
422
+ a.href = url;
423
+ a.download = `${active?.name || "page"}.html`;
424
+ a.click();
425
+ URL.revokeObjectURL(url);
426
+ }
427
+
428
+ const peekSections = [
429
+ { key: "html", title: "Structure", text: code.html },
430
+ { key: "css", title: "Looks", text: code.css },
431
+ { key: "js", title: "Actions", text: code.js },
432
+ ];
433
+
434
+ return (
435
+ <main className="min-h-screen bg-background text-foreground p-3 md:p-6">
436
+ <Stack gap={4}>
437
+ <Row gap={2} align="center" wrap>
438
+ <Heading level={1}>Page Blocks</Heading>
439
+ <Spacer />
440
+ {!ready && <Skeleton variant="row" />}
441
+ {ready && projects.map((p) => (
442
+ <Button key={p._id} size="sm" variant={p._id === active?._id ? "solid" : "quiet"} onClick={() => pickProject(p._id)}>{p.name}</Button>
443
+ ))}
444
+ {canMake && <Button size="sm" variant="quiet" onClick={() => createProject("New page", STARTER)}>New page</Button>}
445
+ {active && <Badge tone={saveState === "saved" ? "up" : "idle"}>{saveState === "saved" ? "Saved" : "Saving…"}</Badge>}
446
+ {active && ready && !canEdit && <Badge tone="info">View only</Badge>}
447
+ </Row>
448
+ {ready && !me && <Row gap={2} align="center" wrap><Text muted>You are building on a scratch pad.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in to keep this page</Button></Row>}
449
+ {ready && me && !active && <Row gap={2} align="center" wrap><Text muted>Like what you built?</Text><Button size="sm" variant="quiet" onClick={keepScratch}>Keep it as my first page</Button></Row>}
450
+
451
+ <div className="sm:hidden">
452
+ <Segmented label="Blocks or preview" value={pane} onChange={setPane} options={[{ value: "blocks", label: "Blocks" }, { value: "preview", label: "Preview" }]} />
453
+ </div>
454
+
455
+ <Grid cols={2} gap={4}>
456
+ <section className={pane === "blocks" ? "block" : "hidden sm:block"}>
457
+ <Card variant="flush">
458
+ <div className="relative h-96 md:h-screen">
459
+ <div ref={hostRef} className="absolute inset-0 touch-none" />
460
+ </div>
461
+ </Card>
462
+ </section>
463
+ <section className={pane === "preview" ? "block" : "hidden sm:block"}>
464
+ <Stack gap={3}>
465
+ <Row gap={2} align="center" wrap>
466
+ <Heading level={2}>Live page</Heading>
467
+ <Spacer />
468
+ <Switch label="Code Peek" checked={peek} onChange={setPeek} />
469
+ <Button size="sm" onClick={download}>Download</Button>
470
+ </Row>
471
+ <Card variant="flush">
472
+ <iframe title="Live page" sandbox="allow-scripts" srcDoc={code.full} className="block w-full h-96 bg-card" />
473
+ </Card>
474
+ {peek && peekSections.map((s) => (
475
+ <Card key={s.key} variant="tight">
476
+ <Stack gap={2}>
477
+ <Badge tone="info">{s.title}</Badge>
478
+ {s.text ? <pre className="font-mono text-sm whitespace-pre-wrap break-words bg-muted rounded-md p-3 max-h-64 overflow-y-auto">{s.text}</pre> : <Text size="sm" muted>Nothing here yet.</Text>}
479
+ </Stack>
480
+ </Card>
481
+ ))}
482
+ </Stack>
483
+ </section>
484
+ </Grid>
485
+ </Stack>
486
+ </main>
487
+ );
488
+ }
489
+ ```
490
+
491
+ ### `access.js`
492
+ ```js
493
+ // project: a page. Its creator owns it; the creator may also let a named helper in.
494
+ // membership: { projectId, handle, addedBy } lets one more person edit one project.
495
+ export function pageBlocks(doc, oldDoc, user, ctx) {
496
+ if (!user) throw { forbidden: "sign in to build pages" };
497
+
498
+ if (doc._deleted) {
499
+ if (oldDoc && oldDoc.type === "project") {
500
+ ctx.requireAccess(`project:${oldDoc._id}/admin`);
501
+ return { channels: [`project:${oldDoc._id}`] };
502
+ }
503
+ if (oldDoc && oldDoc.type === "membership") {
504
+ ctx.requireAccess(`project:${oldDoc.projectId}/admin`);
505
+ return { channels: [`project:${oldDoc.projectId}`] };
506
+ }
507
+ throw { forbidden: "nothing to delete here" };
508
+ }
509
+
510
+ if (doc.type === "project") {
511
+ const ch = `project:${doc._id}`;
512
+ if (oldDoc) {
513
+ if (doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "creator is fixed" };
514
+ ctx.requireAccess(ch);
515
+ } else if (doc.creatorHandle !== user.userHandle) {
516
+ throw { forbidden: "projects are created as yourself" };
517
+ }
518
+ return { channels: [ch], grant: { users: { [doc.creatorHandle]: [ch, `${ch}/admin`] } } };
519
+ }
520
+
521
+ if (doc.type === "membership") {
522
+ const ch = `project:${doc.projectId}`;
523
+ if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
524
+ ctx.requireAccess(`${ch}/admin`);
525
+ return { channels: [ch], grant: { users: { [doc.handle]: [ch] } } };
526
+ }
527
+
528
+ throw { forbidden: "unknown document type" };
529
+ }
530
+ ```
531
+
532
+ ## Mounting the workspace in React
533
+
534
+ **Inject the workspace once inside `useEffect(() => {…}, [])`, keep it in a
535
+ ref, and dispose the workspace in the effect's cleanup.** Blockly owns its own
536
+ DOM under the host element; React renders the chrome around it. Per-change
537
+ values the engine needs (the open project, whether this person may edit) travel
538
+ through refs the change listener reads, so the listener installed on mount sees
539
+ the current project without re-injecting.
540
+
541
+ **Give the host a real height.** Blockly sizes its SVG from the host element,
542
+ and an auto-height `<div>` is 0px tall, which renders as a toolbox with nothing
543
+ beside it. The shape above — a wrapper with `h-96 md:h-screen` and the host as
544
+ `absolute inset-0` inside it — is the one that works everywhere. A
545
+ `ResizeObserver` on the host calling `Blockly.svgResize(ws)` keeps the
546
+ workspace right when the layout changes; call it too when a hidden tab becomes
547
+ visible, since a workspace laid out at `display: none` measures as empty.
548
+
549
+ Options that matter:
550
+
551
+ - `renderer: "zelos"` is the Scratch 3.0 look — rounded, big targets, friendly
552
+ on a phone. The default (`thrasos`) is the compact classic look, right for a
553
+ dense rules editor an analyst uses at a desk.
554
+ - `zoom: { controls: true, wheel: true, pinch: true, startScale: 0.85 }` —
555
+ zelos blocks are large, so start a little zoomed out. `pinch` is the phone's
556
+ zoom.
557
+ - `move: { scrollbars: true, drag: true, wheel: false }` — dragging the
558
+ background pans; the wheel zooms instead of scrolling the page away.
559
+ - `trashcan: true`, `sounds: false`. Keyboard navigation is built in since
560
+ v13, so Tab reaches the blocks with no extra setup.
561
+ - `touch-none` on the host (and on the wrapper, when overlays render inside
562
+ it) so a drag is a drag rather than a page scroll.
563
+
564
+ ## Defining blocks
565
+
566
+ **Define blocks with `Blockly.defineBlocksWithJsonArray` and give each
567
+ connection a check string**, so a block only snaps where its family belongs.
568
+ `message0: "heading %1 named %2"` interleaves label text with `args0`, one
569
+ entry per `%n`; `message1`/`args1` adds a second row. Field types a vibe uses:
570
+ `field_input` (text), `field_dropdown` (`options: [["label", "value"], …]`),
571
+ `field_number` (`value`, `min`, `max`, `precision`), `field_checkbox`. Inputs:
572
+ `input_value` (a round socket for a reporter block, `check: "Boolean"` or
573
+ `"Number"`), `input_statement` (a C-shaped slot, `check: "look"`),
574
+ `input_dummy` (a row with fields only). `inputsInline: true` puts a block's
575
+ inputs on one row — the right call for `a and b`.
576
+
577
+ Connections: `previousStatement`/`nextStatement` make a stackable statement
578
+ block, `output` makes a reporter that plugs into a socket; the value of each is
579
+ the check (`"piece"`, `"look"`, `"Boolean"`), and a socket accepts a block
580
+ only when the checks share a value. That is the type system: a `look` block
581
+ dropped onto a `piece` slot bounces off, so the generator meets only shapes it
582
+ knows. `colour` is a hue 0–360 (or a `blockStyles` name from the theme);
583
+ `tooltip` shows on hover. `getFieldValue("NAME")` returns a string for text and
584
+ dropdown fields and a number for `field_number`.
585
+
586
+ The standard blocks arrive with the bundle. A toolbox category listing
587
+ `controls_if`, `logic_compare`, `controls_repeat_ext`, `math_number`,
588
+ `math_arithmetic`, `text`, `text_print`, `text_join`, `lists_create_with`
589
+ gives a coding playground its vocabulary, and `{ kind: "category", name:
590
+ "Variables", custom: "VARIABLE" }` adds the variable-making category.
591
+
592
+ ## The toolbox
593
+
594
+ A `categoryToolbox` has categories (`name`, `colour`, `contents`); a
595
+ `flyoutToolbox` is one strip of blocks and suits a rules editor with five
596
+ pieces. A block item may preset its fields and pre-fill its sockets:
597
+
598
+ ```js
599
+ { kind: "block", type: "controls_repeat_ext",
600
+ inputs: { TIMES: { shadow: { type: "math_number", fields: { NUM: 3 } } } } }
601
+ ```
602
+
603
+ A **shadow** block is a default value the person can type into or replace by
604
+ dropping a real block over it — give every socket one, so a block dragged from
605
+ the toolbox already does something. `{ kind: "label", text: "Pieces" }` and
606
+ `{ kind: "sep" }` organise a flyout. `ws.updateToolbox(newToolbox)` swaps the
607
+ toolbox at runtime — the way a rules editor offers the fields of whichever
608
+ record type the person picked.
609
+
610
+ ## Generators — blocks become text, and the text is yours to define
611
+
612
+ Blockly's generator turns a workspace into a string. **Each block type gets one
613
+ function on `generator.forBlock`**, called with `(block, generator)`:
614
+ a statement block returns the code string for itself, a reporter (value) block
615
+ returns `[code, order]`, where `order` is the precedence the caller uses to
616
+ decide on parentheses (`Order.ATOMIC` when the output is already a unit).
617
+ Inside a function, `generator.valueToCode(block, "TEST", Order.ATOMIC)` compiles
618
+ the block plugged into a socket (empty string when nothing is plugged in — give
619
+ it a fallback), and `generator.statementToCode(block, "THEN")` compiles the
620
+ whole stack inside a C-slot, indented. `generator.workspaceToCode(ws)` compiles
621
+ every top-level stack; `generator.blockToCode(block)` compiles one stack, which
622
+ is how the starter picks only `piece_` stacks and leaves a loose `look` block on
623
+ the canvas alone.
624
+
625
+ ### Your own generator
626
+
627
+ `new Blockly.CodeGenerator("PageHtml")` is an empty generator; set `scrub_` so
628
+ a statement's output is followed by the block beneath it (the recipe above
629
+ concatenates; a JSON generator joins with `",\n"`), then fill `forBlock`. Call
630
+ `generator.init(workspace)` before a pass. The output is any text at all: HTML,
631
+ CSS, a JSON rule tree, a list of steps, a cron line, SQL-shaped filters your app
632
+ interprets. **Compile blocks to data your own code interprets whenever the
633
+ blocks describe rules or settings**, because a JSON tree is safe to evaluate on
634
+ any record anywhere — in the browser for a live match count and in
635
+ `backend.js` when a record arrives — and your evaluator can only do the things
636
+ it has a `case` for. Here is the whole shape for a rules builder:
637
+
638
+ ### `lib/rules.js`
639
+ ```js
640
+ import * as Blockly from "https://esm.sh/blockly@13.3.0?bundle";
641
+
642
+ export { Blockly };
643
+
644
+ const FIELDS = [["total", "total"], ["quantity", "quantity"], ["region", "region"], ["status", "status"]];
645
+
646
+ Blockly.defineBlocksWithJsonArray([
647
+ {
648
+ type: "rule_when",
649
+ message0: "when a record arrives %1 if %2 %3 then %4",
650
+ args0: [
651
+ { type: "input_dummy" },
652
+ { type: "input_value", name: "TEST", check: "Boolean" },
653
+ { type: "input_dummy" },
654
+ { type: "input_statement", name: "THEN", check: "outcome" },
655
+ ],
656
+ colour: 290,
657
+ tooltip: "One rule. Several can live on the canvas; each is checked against every record.",
658
+ },
659
+ {
660
+ type: "cond_compare",
661
+ message0: "%1 %2 %3",
662
+ args0: [
663
+ { type: "field_dropdown", name: "FIELD", options: FIELDS },
664
+ { type: "field_dropdown", name: "OP", options: [["is over", ">"], ["is under", "<"], ["is", "=="], ["is not", "!="]] },
665
+ { type: "field_input", name: "VALUE", text: "500" },
666
+ ],
667
+ output: "Boolean",
668
+ colour: 210,
669
+ },
670
+ {
671
+ type: "cond_both",
672
+ message0: "%1 %2 %3",
673
+ args0: [
674
+ { type: "input_value", name: "A", check: "Boolean" },
675
+ { type: "field_dropdown", name: "OP", options: [["and", "and"], ["or", "or"]] },
676
+ { type: "input_value", name: "B", check: "Boolean" },
677
+ ],
678
+ inputsInline: true,
679
+ output: "Boolean",
680
+ colour: 210,
681
+ },
682
+ {
683
+ type: "outcome_tag",
684
+ message0: "tag it %1",
685
+ args0: [{ type: "field_input", name: "TAG", text: "review" }],
686
+ previousStatement: "outcome",
687
+ nextStatement: "outcome",
688
+ colour: 120,
689
+ },
690
+ {
691
+ type: "outcome_route",
692
+ message0: "send it to %1",
693
+ args0: [{ type: "field_dropdown", name: "QUEUE", options: [["the manager", "manager"], ["billing", "billing"], ["the archive", "archive"]] }],
694
+ previousStatement: "outcome",
695
+ nextStatement: "outcome",
696
+ colour: 120,
697
+ },
698
+ ]);
699
+
700
+ export const toolbox = {
701
+ kind: "flyoutToolbox",
702
+ contents: [
703
+ { kind: "block", type: "rule_when" },
704
+ { kind: "block", type: "cond_compare" },
705
+ { kind: "block", type: "cond_both" },
706
+ { kind: "block", type: "outcome_tag" },
707
+ { kind: "block", type: "outcome_route" },
708
+ ],
709
+ };
710
+
711
+ // The generator emits JSON text. Value blocks return [code, order]; statement blocks return code.
712
+ const json = new Blockly.CodeGenerator("RuleJson");
713
+ const ORDER = { ATOMIC: 0 };
714
+ json.scrub_ = function (block, code, thisOnly) {
715
+ const next = block.nextConnection && block.nextConnection.targetBlock();
716
+ return next && !thisOnly ? code + ",\n" + this.blockToCode(next) : code;
717
+ };
718
+ json.forBlock["rule_when"] = (b, gen) => {
719
+ const test = gen.valueToCode(b, "TEST", ORDER.ATOMIC) || "true";
720
+ const outcomes = gen.statementToCode(b, "THEN");
721
+ return `{ "if": ${test}, "then": [\n${outcomes}\n] }`;
722
+ };
723
+ json.forBlock["cond_compare"] = (b) => {
724
+ const raw = b.getFieldValue("VALUE");
725
+ const value = Number.isFinite(Number(raw)) && raw.trim() !== "" ? Number(raw) : raw;
726
+ return [JSON.stringify({ field: b.getFieldValue("FIELD"), op: b.getFieldValue("OP"), value }), ORDER.ATOMIC];
727
+ };
728
+ json.forBlock["cond_both"] = (b, gen) => {
729
+ const a = gen.valueToCode(b, "A", ORDER.ATOMIC) || "true";
730
+ const c = gen.valueToCode(b, "B", ORDER.ATOMIC) || "true";
731
+ return [`{ "${b.getFieldValue("OP")}": [${a}, ${c}] }`, ORDER.ATOMIC];
732
+ };
733
+ json.forBlock["outcome_tag"] = (b) => ` ${JSON.stringify({ tag: b.getFieldValue("TAG") })}`;
734
+ json.forBlock["outcome_route"] = (b) => ` ${JSON.stringify({ route: b.getFieldValue("QUEUE") })}`;
735
+
736
+ /** Every top-level rule on the canvas, parsed — saved beside the blocks on the ruleset document. */
737
+ export function compileRules(workspace) {
738
+ json.init(workspace);
739
+ const rules = [];
740
+ for (const top of workspace.getTopBlocks(true)) {
741
+ if (top.type === "rule_when") rules.push(JSON.parse(json.blockToCode(top)));
742
+ }
743
+ return rules;
744
+ }
745
+
746
+ // Plain data in, plain data out: the same function runs in the browser for the live match count
747
+ // and in backend.js when a record arrives.
748
+ export function test(cond, record) {
749
+ if (cond === true) return true;
750
+ if (cond.and) return cond.and.every((c) => test(c, record));
751
+ if (cond.or) return cond.or.some((c) => test(c, record));
752
+ const actual = record[cond.field];
753
+ switch (cond.op) {
754
+ case ">": return Number(actual) > Number(cond.value);
755
+ case "<": return Number(actual) < Number(cond.value);
756
+ case "==": return String(actual) === String(cond.value);
757
+ case "!=": return String(actual) !== String(cond.value);
758
+ default: return false;
759
+ }
760
+ }
761
+
762
+ export function outcomesFor(rules, record) {
763
+ return rules.filter((rule) => test(rule.if, record)).flatMap((rule) => rule.then);
764
+ }
765
+
766
+ export const STARTER = {
767
+ blocks: {
768
+ languageVersion: 0,
769
+ blocks: [
770
+ {
771
+ type: "rule_when",
772
+ x: 24,
773
+ y: 24,
774
+ inputs: {
775
+ TEST: {
776
+ block: {
777
+ type: "cond_both",
778
+ fields: { OP: "and" },
779
+ inputs: {
780
+ A: { block: { type: "cond_compare", fields: { FIELD: "total", OP: ">", VALUE: "500" } } },
781
+ B: { block: { type: "cond_compare", fields: { FIELD: "region", OP: "==", VALUE: "west" } } },
782
+ },
783
+ },
784
+ },
785
+ THEN: { block: { type: "outcome_tag", fields: { TAG: "review" }, next: { block: { type: "outcome_route", fields: { QUEUE: "manager" } } } } },
786
+ },
787
+ },
788
+ ],
789
+ },
790
+ };
791
+ ```
792
+
793
+ The shell around it is the page builder's shell with two swaps: the preview
794
+ pane is a list of the app's records with each one's outcomes beside it
795
+ (`outcomesFor(rules, record)`), headed by a live count — "matches 12 of 40" —
796
+ and the autosave writes `{ workspaceJson, rules: compileRules(ws) }` on the
797
+ ruleset document, so `backend.js` reads `rules` and evaluates the saved rule
798
+ data with `outcomesFor` on every record that lands. The starter
799
+ rule reads: when a record arrives, if total is over 500 and region is west,
800
+ tag it "review" and send it to the manager.
801
+
802
+ ### The JavaScript generator and running its output
803
+
804
+ For a coding playground, the standard blocks plus `javascriptGenerator` give
805
+ you real JavaScript for free; add `javascriptGenerator.forBlock["my_block"]`
806
+ entries for your own blocks in the same style (`(block, generator) => …`,
807
+ with `Order` from the same import for reporters).
808
+ **Run generated JavaScript inside a sandboxed iframe**
809
+ (`<iframe sandbox="allow-scripts" srcDoc={doc} />`, a document that wraps the code in a `try`, swaps `window.alert`/`console.log`
810
+ for `parent.postMessage`, and the parent listens on `message` to show the
811
+ output). The iframe is a fresh origin with no access to the app's page, data
812
+ or identity, so the same stage shows a kid's loop and a stranger's remix
813
+ equally safely. **Bound every generated loop with the loop trap**, since a
814
+ sandboxed frame still shares the tab's thread and a `while (true)` with no
815
+ exit would hold the editor too:
816
+
817
+ ```js
818
+ javascriptGenerator.INFINITE_LOOP_TRAP = 'if (--window.LoopTrap === 0) throw "Infinite loop.";\n';
819
+ // and in the stage document, before the generated code: window.LoopTrap = 10000;
820
+ ```
821
+
822
+ Every `while`, `repeat` and `for` the standard blocks emit then counts down
823
+ and stops with a readable message after ten thousand turns, which the stage
824
+ shows like any other output, and the Run button stays responsive.
825
+
826
+ Step highlighting is two lines: `javascriptGenerator.STATEMENT_PREFIX =
827
+ "highlightBlock(%1);\n"` and `javascriptGenerator.addReservedWords("highlightBlock")`
828
+ make every statement begin with a call carrying its block id; the iframe posts
829
+ those ids up and the parent calls `ws.highlightBlock(id)` (and
830
+ `ws.highlightBlock(null)` when the run ends).
831
+
832
+ ## Live output as blocks change
833
+
834
+ `ws.addChangeListener((e) => { if (e.isUiEvent) return; … })` fires on every
835
+ workspace event; `isUiEvent` marks clicks, selection, scroll and toolbox
836
+ opens, so the early return keeps regeneration to real edits — a block created,
837
+ moved, deleted, a field changed. Regenerate the code in the listener (it is
838
+ cheap; the page builder recompiles the whole page on every event) and let a
839
+ debounce own the save.
840
+
841
+ **Load a project with events disabled, then regenerate once**: wrap `ws.clear()`
842
+ and `Blockly.serialization.workspaces.load(state, ws)` in
843
+ `Blockly.Events.disable()` … `Blockly.Events.enable()` (in a `try`/`finally`)
844
+ so the listener stays quiet while the stack is rebuilt, then call `compile(ws)`
845
+ yourself. That is what keeps a project switch from autosaving the incoming
846
+ project over itself.
847
+
848
+ ## Persistence — save the blocks, regenerate the code
849
+
850
+ **Store the workspace JSON from `Blockly.serialization.workspaces.save`, and
851
+ regenerate code on load.** The object it returns (`{ blocks: { languageVersion:
852
+ 0, blocks: [...] }, variables? }`) is the project; store it as `workspaceJson`
853
+ on the project document. The generated page, rule or script is derived, so a
854
+ generator fix reaches every saved project the next time it opens. When the
855
+ server needs the compiled form too (the rules case), save both — blocks for
856
+ the editor, `rules` for `backend.js` — and treat `rules` as a cache the editor
857
+ rewrites on every save.
858
+
859
+ Autosave on a short debounce (~800ms after the last real event), flush the
860
+ pending save before switching projects, and write only when
861
+ `can.edit(project).ok`: a viewer who opens a shared project edits freely on
862
+ their own screen and keeps a view-only badge in the header. Seed two or three
863
+ finished projects in `seed.json` so a first visit opens on a page or a rule that
864
+ already does something, and let a signed-out visitor build on a scratch pad
865
+ with "Keep it" as the moment to sign in.
866
+
867
+ ## Colour — a Blockly theme from the app's tokens
868
+
869
+ Blockly draws its own chrome inside the host, so the app's theme reaches it
870
+ through a Blockly theme, built from the injected CSS variables resolved to
871
+ concrete colours at mount (the same probe-element trick the p5 and three-js
872
+ docs use — a `var(--background, #fff)` on a hidden span, read back with
873
+ `getComputedStyle`):
874
+
875
+ ```js
876
+ const theme = Blockly.Theme.defineTheme("vibe", {
877
+ base: Blockly.Themes.Classic,
878
+ componentStyles: {
879
+ workspaceBackgroundColour: cssColor(host, "--background", "#ffffff"),
880
+ toolboxBackgroundColour: cssColor(host, "--surface", "#f4f4f5"),
881
+ flyoutBackgroundColour: cssColor(host, "--surface", "#f4f4f5"),
882
+ toolboxForegroundColour: cssColor(host, "--text-primary", "#111111"),
883
+ scrollbarColour: cssColor(host, "--border", "#cccccc"),
884
+ },
885
+ fontStyle: { family: "system-ui, sans-serif", size: 12 },
886
+ });
887
+ const ws = Blockly.inject(host, { toolbox, theme, renderer: "zelos" });
888
+ ```
889
+
890
+ Block colours stay as hues per family (structure, looks, actions), which is how
891
+ Scratch teaches: a kid learns that blue blocks are looks before reading a word.
892
+ `ws.setTheme(theme)` swaps the theme on a live workspace when the owner
893
+ restyles the app.
894
+
895
+ ## Mobile
896
+
897
+ The zelos renderer, `pinch` zoom, `touch-none` on the host and a `Segmented`
898
+ control that shows blocks or the preview one at a time under `sm` make the same
899
+ app work on a phone. `toolboxPosition: "end"` puts the toolbox on the right
900
+ for a right-handed thumb; `horizontalLayout: true` lays the categories across
901
+ the top when the screen is short. Call `Blockly.svgResize(ws)` after the pane
902
+ switch so the workspace measures its newly visible host.
903
+
904
+ ## Shapes a Blockly vibe takes
905
+
906
+ - **A rules engine the owner of the policy edits** — credit scoring, fraud
907
+ flags, approval thresholds, compliance checks, discount and pricing rules,
908
+ eligibility, routing and triage: "if the total is over 500 and the region is
909
+ west, tag it for review". Blocks compile to a JSON rule tree; the editor
910
+ shows a live count of the app's records each rule matches, with the matching
911
+ records listed beneath so a change is checked against real data before it
912
+ is saved; `backend.js` runs the same `outcomesFor` when a record arrives.
913
+ The analyst, the bookkeeper or the operations lead edits the policy without
914
+ a release, and the connection checks mean every rule they can build is one
915
+ the evaluator understands. Field dropdowns come from the record type, so
916
+ `ws.updateToolbox` offers the right fields when the type changes.
917
+ - **A CRM, ERP or ticket workflow automation** — "when a form comes in, send
918
+ it to billing and tag it urgent", "when a deal passes 10,000, assign it to
919
+ the manager and schedule a follow-up": trigger blocks over the app's record
920
+ types, condition blocks over their fields, action blocks over its queues,
921
+ tags, assignees and dates, compiled to the same data shape and run
922
+ server-side when the record lands or on a schedule (the backend skill has
923
+ both handlers). A log of which rule fired on which record is the audit
924
+ trail.
925
+ - **A low-code form or screen builder** — field, section and validation blocks
926
+ compile to a form definition the app renders and validates; the same block
927
+ set drives a settings screen or an intake flow.
928
+ - **A test or checklist builder** — "open the page, fill the field, expect this
929
+ text", or an API check with a mock response and an expected value: step
930
+ blocks compile to a plan the app runs step by step, highlighting the current
931
+ block, with pass and fail marks beside each step.
932
+ - **A page builder for a kid** — structure, style and action blocks compile
933
+ to one HTML document shown live in a sandboxed iframe, with Code Peek
934
+ showing the real HTML, CSS and JavaScript the blocks wrote and a download
935
+ of the finished page. The worked example above.
936
+ - **A coding playground** — the standard blocks and `javascriptGenerator`, a
937
+ Run button, output in the sandboxed stage, step highlighting on the canvas.
938
+ - **A device, robot or conveyor sequence** — move/turn/wait/pick-up blocks
939
+ that drive an SVG turtle, an animated arm or a line diagram so a technician
940
+ rehearses a sequence before it runs; a schedule builder whose blocks compile
941
+ to the times a scheduled handler reads.
942
+
943
+ Each is the same three parts: a block vocabulary with checks, a generator whose
944
+ output your code understands, and a stage that shows the result the moment a
945
+ block lands.
946
+
947
+ ## Common gotchas
948
+
949
+ - **The workspace renders as a toolbox with nothing beside it** — the host has
950
+ no height. Size the wrapper and make the host `absolute inset-0`.
951
+ - **The standard blocks are missing or `Blockly.Blocks` is empty** — a second
952
+ Blockly instance. One import, with `?bundle`, re-exported from one module.
953
+ - **Every project switch autosaves the wrong blocks** — the listener ran during
954
+ `load`. Disable events around the load and regenerate once after it. And
955
+ flush the pending debounce before switching or creating a project, so the
956
+ last edit lands on the project it belongs to.
957
+ - **Generated HTML breaks on a `<` in a field** — escape field text on the way
958
+ into markup, and `JSON.stringify` anything on the way into a script string.
959
+ - **`valueToCode` returns an empty string** — the socket is empty; give the
960
+ call a fallback (`|| "true"`) or put a shadow block in the toolbox entry.
961
+ - **A hidden pane shows a blank workspace when it appears** — call
962
+ `Blockly.svgResize(ws)` after the pane becomes visible.
963
+ - **Dropdown values arrive as strings** — a numeric choice such as a size is
964
+ a string on the block; convert where the generator needs a number.