@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.
- package/llms/access.md +105 -15
- package/llms/blockly.d.ts +2 -0
- package/llms/blockly.js +59 -0
- package/llms/blockly.js.map +1 -0
- package/llms/blockly.md +964 -0
- package/llms/fireproof.md +4 -4
- package/llms/index.d.ts +7 -1
- package/llms/index.js +18 -0
- package/llms/index.js.map +1 -1
- package/llms/motion.d.ts +2 -0
- package/llms/motion.js +47 -0
- package/llms/motion.js.map +1 -0
- package/llms/motion.md +601 -0
- package/llms/physics.d.ts +2 -0
- package/llms/physics.js +44 -0
- package/llms/physics.js.map +1 -0
- package/llms/physics.md +635 -0
- package/llms/richtext.d.ts +2 -0
- package/llms/richtext.js +47 -0
- package/llms/richtext.js.map +1 -0
- package/llms/richtext.md +612 -0
- package/llms/strudel.d.ts +2 -0
- package/llms/strudel.js +36 -0
- package/llms/strudel.js.map +1 -0
- package/llms/strudel.md +686 -0
- package/llms/web-audio.md +2 -0
- package/llms/worlds.d.ts +2 -0
- package/llms/worlds.js +45 -0
- package/llms/worlds.js.map +1 -0
- package/llms/worlds.md +826 -0
- package/package.json +5 -4
- package/restyle.js +3 -2
- package/restyle.js.map +1 -1
- package/system-prompt-initial-oneshot.md +3 -2
- package/system-prompt-initial.md +3 -2
- package/system-prompt.md +6 -3
package/llms/blockly.md
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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.
|