@eventmodelers/cli 2.0.34 → 2.0.36
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/extensions/prototype/local-build.js +14 -0
- package/extensions/prototype/skills/local-prototype-build/SKILL.md +1 -1
- package/package.json +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +27 -18
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/index.template.html +131 -51
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +22 -2
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/scaffold.mjs +192 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { skeleton } from '../../stacks/modeling-kit/templates/.claude/skills/prototype/assets/scaffold.mjs';
|
|
7
8
|
import { loadScreens, loadSlices, paths, readJson, sliceHash, writeJson } from './state.js';
|
|
8
9
|
|
|
9
10
|
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
@@ -61,12 +62,24 @@ export function status(cwd) {
|
|
|
61
62
|
const p = paths(cwd);
|
|
62
63
|
const { byId, ...report } = plan(cwd);
|
|
63
64
|
mkdirSync(p.blocks, { recursive: true });
|
|
65
|
+
const screens = loadScreens(p);
|
|
66
|
+
const sliceById = Object.fromEntries(loadSlices(p).map(({ slice }) => [slice.id, slice]));
|
|
64
67
|
// Every slice that already has a block keeps it in the work folder: unchanged ones as the page has them, changed ones as the previous
|
|
65
68
|
// version — to be patched in place (Edit) and given the new hash, not rewritten from nothing. Work already done on a changed
|
|
66
69
|
// slice's block is never overwritten by running `status` again. Only new slices start empty.
|
|
67
70
|
for (const s of report.slices) {
|
|
68
71
|
const file = join(p.blocks, `${s.id}.html`);
|
|
69
72
|
if (byId[s.id] && (s.state === 'unchanged' || !existsSync(file))) writeFileSync(file, byId[s.id].block + '\n');
|
|
73
|
+
// The deterministic part of a new or changed slice's block — specs from the scenarios, screens as JSX, a defineSlice stub — is generated
|
|
74
|
+
// (shared with the platform skill); the agent only fills the @@TODO. A block already being worked on is left alone.
|
|
75
|
+
if (s.state !== 'unchanged') {
|
|
76
|
+
const slice = sliceById[s.id];
|
|
77
|
+
const own = (slice.screens || []).map((x) => ({ title: x.title, lane: x.lane, ...(screens[x.linkedId || x.id] || {}) }));
|
|
78
|
+
if (byId[s.id]) { mkdirSync(join(p.work, 'previous'), { recursive: true }); writeFileSync(join(p.work, 'previous', `${s.id}.html`), byId[s.id].block + '\n'); }
|
|
79
|
+
if (!existsSync(file) || (byId[s.id] && readFileSync(file, 'utf8').trim() === byId[s.id].block.trim())) {
|
|
80
|
+
writeFileSync(file, skeleton({ slice, hash: s.hash, order: s.order, specSlice: slice, screens: own }).block);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
70
83
|
}
|
|
71
84
|
writeJson(join(p.work, 'status.json'), report);
|
|
72
85
|
return report;
|
|
@@ -88,6 +101,7 @@ export function assemble(cwd) {
|
|
|
88
101
|
const [found] = blocksIn(block);
|
|
89
102
|
if (!found || found.id !== s.id) problems.push(`${file}: not a slice block for ${s.id}`);
|
|
90
103
|
else if (found.hash !== s.hash) problems.push(`${file}: data-em-hash is ${found.hash}, the slice's hash is ${s.hash}`);
|
|
104
|
+
if (block.includes('@@TODO')) problems.push(`${file}: still has @@TODO — fill every one`);
|
|
91
105
|
if ((block.match(/<\/script/gi) || []).length !== 1) problems.push(`${file}: a closing script tag appears inside the block`);
|
|
92
106
|
return block;
|
|
93
107
|
});
|
|
@@ -14,7 +14,7 @@ The page `.prototype/index.html` is derived from the model: slice by slice, one
|
|
|
14
14
|
{{CLI}} prototype status
|
|
15
15
|
```
|
|
16
16
|
Lists every slice as `new`, `changed` or `unchanged`, and puts the blocks of unchanged slices into `.prototype/work/slices/`. The hash covers the slice's elements, scenarios and its screens — not its status or comments.
|
|
17
|
-
2. **
|
|
17
|
+
2. **Complete the block of every `new` and `changed` slice** in `.prototype/work/slices/<slice id>.html`. `status` has already generated its deterministic part — the script tag with the right hash, `GENERATED_SPECS` (every scenario and storyline), each HTML page of its screens as a JSX component, and a `defineSlice` stub with `@@TODO` — so only the judgment is left: `decide` / `evolve` / `process` and wiring the screen part to the hooks. `Edit` the skeleton in place (never rewrite it); `assemble` refuses a block that still has `@@TODO`. A `changed` slice's previous block is in `.prototype/work/previous/<slice id>.html` for reference. Read the slice from `.slices/<context>/<slice>/slice.json` and its screens from `.prototype/screens/<node id>/` (`screen.json` + `page-N.html`). Slices are independent: one after another works everywhere; if your environment can run sub-agents, you may give each slice's file to its own worker (about six at a time) and redo any that fail serially.
|
|
18
18
|
How to write a block — its logic from the scenarios, its part of the screen, its exact shape — is `references/block-rules.md`; follow its steps 4, 5 and 6, and the contract in `references/slices.example.html`. Ignore everything there about `connect`, MCP tools, reading from the board, `prototype.mjs` and uploading: the files above replace all of it.
|
|
19
19
|
3. **Assemble.**
|
|
20
20
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.36",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,28 +59,30 @@ EM_TOKEN="$TOKEN" node <skill-dir>/assets/prototype.mjs status \
|
|
|
59
59
|
|
|
60
60
|
Per slice, the hash covers its elements with their fields and dependencies, its scenarios and storylines, and the content of its screens (HTML pages and marks, or the sketch) — not its status, assignee, ticket or comments, so moving a slice to Done rebuilds nothing. The hashes are compared with those on the blocks of the prototype the platform stores for this context — the page itself is the memory, no other state is kept — so a run in another chat session, folder or machine still rebuilds only what changed. `status` downloads it itself; the page never passes through you. Only when the platform has none yet does it fall back to a local `<out>/index.html` (an earlier run whose upload failed); `baseline` in `status.json` says which it was (`platform` / `local` / `none`).
|
|
61
61
|
|
|
62
|
-
`<out>/.work/status.json` carries the context's id (`contextId` — the MODEL_CONTEXT or timeline the name matched; the platform resolves a timeline to its effective context when storing) and lists every slice with `state` (`new` / `changed` / `unchanged`), its `hash`, `order` and `sliceType`, plus `removed` slices and the CSS framework the screens use. The blocks of unchanged slices are already copied to `<out>/.work/slices/<slice id>.html`.
|
|
62
|
+
`<out>/.work/status.json` carries the context's id (`contextId` — the MODEL_CONTEXT or timeline the name matched; the platform resolves a timeline to its effective context when storing) and lists every slice with `state` (`new` / `changed` / `unchanged`), its `hash`, `order` and `sliceType`, plus `removed` slices, and the CSS framework the screens use. The blocks of unchanged slices are already copied to `<out>/.work/slices/<slice id>.html`.
|
|
63
63
|
|
|
64
|
-
**
|
|
64
|
+
**For every new or changed slice `status` also writes the deterministic part of its block** — you don't write it:
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
| File | What it is |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `<out>/.work/slices/<slice id>.html` | the block **skeleton**: script tag with the right hash and `order`, `GENERATED_SPECS` (every scenario and storyline as a spec entry, example values typed), one component per HTML page of its screens (translated to JSX 1:1), and a `defineSlice` stub with `@@TODO` where judgment is needed |
|
|
69
|
+
| `<out>/.work/data/<slice id>.json` | the slice as the board has it: `slice` (elements with fields, descriptions, dependencies), `specs` (scenarios, storylines) and `screens` (pages, marks, or sketch) — **read this instead of calling the board again** |
|
|
70
|
+
| `<out>/.work/previous/<slice id>.html` | for a `changed` slice, the block as it was — reference only |
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
`assemble` refuses a block that still contains `@@TODO`. `// NEEDS:` lines in a skeleton name scenarios the generator could not turn into a spec; write those by hand.
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
**Nothing new or changed and nothing removed** → the local page is up to date, but that says nothing about the platform's copy (an earlier run may have stopped before uploading). Skip Steps 3–5 and 6's block work, but still write the questions (Step 5b), run `assemble` and **Upload** in Step 6 — `assemble` takes the template and runtime fresh from the skill, so a kit fix to the harness reaches the page even when the board didn't change — then report.
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
A change in one slice can matter to another (a renamed event field that a read model in another slice reads): that slice's own JSON changes with it, so it is flagged too. Build only what is flagged.
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
mcp__eventmodelers__get_slice_data { "boardId": "<BOARD_ID>", "contextName": "<CONTEXT>", "sliceIds": ["<id>", …], "format": "json" }
|
|
76
|
-
```
|
|
78
|
+
---
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
## Step 3 — Read the flagged slices
|
|
79
81
|
|
|
80
|
-
|
|
81
|
-
- its specs → `specifications[]` (`given` / `when` / `then` with example values; a rejection is a `then` step of type `SPEC_ERROR` whose `description` is the message; `expectEmptyList` on read-model steps) and `storylines[]`.
|
|
82
|
+
Read `<out>/.work/data/<slice id>.json` for each flagged slice. No board call is needed for them. (`mcp__eventmodelers__get_slice_data` / `get_nodes` only if a file is missing.)
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
- the slice's elements → `commands[]`, `events[]`, `readmodels[]`, `screens[]`, `processors[]` (automations), each element with `fields[]` (`type`, `example`, `generated`, `idAttribute`, `cardinality`, `subfields`, `mapping`), `description` and `dependencies[]` (`INBOUND`/`OUTBOUND`) — the wiring SCREEN → COMMAND → EVENT → READMODEL → SCREEN / AUTOMATION → COMMAND.
|
|
85
|
+
- `specs` → `specifications[]` and `storylines[]`; `screens` → `pages` and `marks` for an HTML screen, `sketch` `{ elements }` for a SCREEN (grid vocabulary in `storyboard-screen`), nothing for an uploaded screenshot (the screen's fields are all there is).
|
|
84
86
|
|
|
85
87
|
**Copies.** An element with `elementCopy: true` is a linked copy (its `id` is the original's). It is defined by the slice that owns the original, never twice. An EVENT whose original lives in another context, or that no COMMAND in this context produces, is an **external event**.
|
|
86
88
|
|
|
@@ -106,7 +108,7 @@ Write each flagged slice's logic before its UI. Names are the board's titles wit
|
|
|
106
108
|
|
|
107
109
|
**External events** — `externalEvents: [{ type, from, fields: [{ name, type, example }] }]` on the slice that consumes them; the simulate panel records them, which is how translation automations get triggered.
|
|
108
110
|
|
|
109
|
-
**Specs —
|
|
111
|
+
**Specs — already generated** into `GENERATED_SPECS` from the scenarios and storylines (the table below is the mapping the generator applies). Check it against the scenarios you analysed, fix what is off, and write any `// NEEDS:` entry by hand:
|
|
110
112
|
|
|
111
113
|
| Scenario | Spec entry |
|
|
112
114
|
|---|---|
|
|
@@ -122,6 +124,8 @@ Example values become typed data (`Int`/`Double` → numbers, `Boolean` → bool
|
|
|
122
124
|
|
|
123
125
|
## Step 5 — The slice's part of the screen
|
|
124
126
|
|
|
127
|
+
**The skeleton already holds each HTML page translated to JSX** — one component per page, markup 1:1, inline handlers dropped. What is left is judgment: cut the component to this slice's region, replace sample data with the hooks, wire the buttons. Do that by editing the skeleton in place (small edits), never by rewriting the page. A sketch screen has no generated component — redraw it as below.
|
|
128
|
+
|
|
125
129
|
**Screens are tabs, one per screen name.** Slices whose screens share a name contribute to the same tab, in timeline order — so a screen shown in three slices with three different marks is one tab with three parts. A slice declares its part as `screens: { '<Screen Title>': { actor: '<lane>', part: Component } }`.
|
|
126
130
|
|
|
127
131
|
**Read the screen HTML for what belongs to this slice:**
|
|
@@ -131,7 +135,7 @@ Example values become typed data (`Int`/`Double` → numbers, `Boolean` → bool
|
|
|
131
135
|
- a button inside a list row navigates with the row's ids (`go('<title>', { orderId })`) or issues that row's command with them.
|
|
132
136
|
- a button the model gives no meaning to: kept as designed, `onClick={() => notModeled('<label>')}` (`const notModeled = useNotModeled()`), and listed in the report.
|
|
133
137
|
|
|
134
|
-
**HTML screens — 1:1**:
|
|
138
|
+
**HTML screens — 1:1**: the generated component is the translation (several pages → a step-through with local `page` state, already generated); do not redesign it. **Sketch screens — redrawn**: a 1000×800 relative container, each element absolutely positioned at `gridX*20`/`gridY*20` (`gridWidth*20` × `gridHeight*20`) — `rectangle` → `div`, `headline`/`text` → text at its `fontSize`, `button` → `<button>`, `input` → `<input placeholder>`, `image` → a filled box, `line`/`circle` → inline SVG — then wired the same way.
|
|
135
139
|
|
|
136
140
|
Automation slices usually have no screen part — the Automations tab shows them.
|
|
137
141
|
|
|
@@ -141,7 +145,7 @@ Automation slices usually have no screen part — the Automations tab shows them
|
|
|
141
145
|
|
|
142
146
|
The prototype also **asks**: a guided tour through the screens, one `?` tag per question on the element it is about, a Next button leading from question to question — also onto another screen. This is how the people playing it tell us what the screens do (what is stored, what happens next, which rules apply). **What to ask, in which order and in which words: `eventmodeling-workshop-questions`** — read it, don't improvise a catalog.
|
|
143
147
|
|
|
144
|
-
**Regenerate the questions on every run, from the model as it is now** — also when every slice is `unchanged`, and never copied from the stored page: a question that was answered by the model since, or points at an element that is gone, must not come back. Write them to `<out>/.work/questions.json` (`assemble` embeds them; without the file the page asks nothing), an array in tour order:
|
|
148
|
+
**Regenerate the questions on every run, from the model as it is now** — also when every slice is `unchanged`, and never copied from the stored page: a question that was answered by the model since, or points at an element that is gone, must not come back, and a fresh look often finds questions the last run did not. Write them to `<out>/.work/questions.json` (`assemble` embeds them; without the file the page asks nothing), an array in tour order:
|
|
145
149
|
|
|
146
150
|
```json
|
|
147
151
|
[{ "id": "save-note-action", "screen": "Activity Composer", "ref": "text:Save note", "category": "action",
|
|
@@ -156,12 +160,17 @@ The prototype also **asks**: a guided tour through the screens, one `?` tag per
|
|
|
156
160
|
|
|
157
161
|
## Step 6 — Write the blocks, assemble, check
|
|
158
162
|
|
|
159
|
-
|
|
163
|
+
Complete each new or changed slice's skeleton in `<out>/.work/slices/<slice id>.html` (Steps 4–5): replace every `@@TODO`, keep the script tag and its hash as generated, keep the shape of `assets/slices.example.html`:
|
|
160
164
|
|
|
161
|
-
- one `<script type="text/plain" data-em-slice
|
|
165
|
+
- one `<script type="text/plain" data-em-slice=… data-em-hash=…>` element whose body ends with one `EMRuntime.defineSlice({ id, title, order, commands, readModels, automations, externalEvents, specs, screens })` call (already in the skeleton);
|
|
162
166
|
- JSX and modern JavaScript; React hooks as `React.useState`; `useCommand`, `useReadModel`, `useNav`, `useNotModeled` are globals;
|
|
163
167
|
- never the text of a closing script tag inside the block, not even in a string or comment.
|
|
164
168
|
|
|
169
|
+
**Slices are independent of each other — each one touches only its own files.** So the work can be split, but it never has to be:
|
|
170
|
+
|
|
171
|
+
- **Default, any agent: one slice after another**, in `order`. Nothing else is needed.
|
|
172
|
+
- **Only if your environment can run sub-agents / parallel workers** (a task or sub-agent tool; check what you actually have, don't assume): hand each flagged slice to one worker — at most about six at a time — with this brief: *"Complete `<out>/.work/slices/<id>.html` following Steps 3–5 and 6 of `<skill-dir>/SKILL.md` for this one slice; read `<out>/.work/data/<id>.json`; edit only that file; reply with the gaps you found (buttons not modeled and so on)."* Meanwhile you write the questions (Step 5b) — they need only `status.json` and the data files — and, when every worker is done, continue with `assemble`. A worker that fails or returns a block with `@@TODO` left is redone serially. With no such tool, ignore this paragraph.
|
|
173
|
+
|
|
165
174
|
Then:
|
|
166
175
|
|
|
167
176
|
```bash
|
|
@@ -34,23 +34,45 @@
|
|
|
34
34
|
.em-badge button:hover { color: #bfdbfe; }
|
|
35
35
|
.em-outline-toggle { display: inline-flex; gap: 5px; align-items: center; font: 12px system-ui; color: #9ca3af; cursor: pointer; }
|
|
36
36
|
.em-part-label { font: 600 11px system-ui; color: #6b7280; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
|
|
37
|
-
.em-panel {
|
|
38
|
-
|
|
37
|
+
.em-panel { --p-bg: #0e1522; --p-surface: #161f30; --p-raised: #1d283c; --p-line: #232f45; --p-text: #e7eaf0; --p-muted: #8a95a9; --p-accent: #f97316;
|
|
38
|
+
background: var(--p-bg); color: var(--p-text); padding: 0 16px 24px; overflow-y: auto; max-height: 100vh; position: sticky; top: 0; font: 13px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
39
|
+
.em-panel::-webkit-scrollbar { width: 8px; } .em-panel::-webkit-scrollbar-thumb { background: var(--p-line); border-radius: 4px; }
|
|
40
|
+
.em-panel h2, .em-auto h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #8a95a9; margin: 0; }
|
|
41
|
+
.em-sect { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 18px 0 12px; min-height: 28px; }
|
|
42
|
+
.em-sect .em-sub { color: var(--p-muted); font-size: 12px; margin-top: 2px; text-transform: none; letter-spacing: 0; font-weight: 400; }
|
|
43
|
+
.em-empty { color: var(--p-muted); background: var(--p-surface); border: 1px dashed var(--p-line); border-radius: 10px; padding: 18px; text-align: center; }
|
|
39
44
|
/* The panel's tabs are real tabs: a strip with a rule under it, the open tab joined to the content below (same colour, no rule under it) with an orange top edge. */
|
|
40
45
|
.em-panel.em-collapsed { padding: 8px 4px; overflow: hidden; }
|
|
41
|
-
.em-toggle { background: #
|
|
42
|
-
.em-toggle:hover { background: #
|
|
46
|
+
.em-toggle { background: #161f30; color: #8a95a9; border: 1px solid #232f45; border-radius: 7px; width: 28px; height: 28px; cursor: pointer; font: 14px system-ui; flex: none; }
|
|
47
|
+
.em-toggle:hover { background: #1d283c; color: #e7eaf0; }
|
|
43
48
|
.em-tabs .em-toggle { width: 28px; height: 28px; padding: 0; margin-bottom: 0; border-radius: 6px; color: #e5e7eb; }
|
|
44
49
|
.em-collapsed .em-toggle { display: block; margin: 0 auto 8px; }
|
|
45
50
|
.em-collapsed .em-vlabel { writing-mode: vertical-rl; color: #9ca3af; font: 12px system-ui; letter-spacing: .06em; text-transform: uppercase; text-align: center; cursor: pointer; width: 100%; }
|
|
46
|
-
.em-tabs { display: flex; gap:
|
|
47
|
-
.em-tabs button { background:
|
|
48
|
-
.em-tabs button:hover { color:
|
|
49
|
-
.em-tabs button.em-active {
|
|
50
|
-
.em-btn { background: #
|
|
51
|
-
.em-btn
|
|
52
|
-
.em-
|
|
53
|
-
.em-
|
|
51
|
+
.em-tabs { display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--p-line); margin: 0 -16px; padding: 10px 16px 0; position: sticky; top: 0; background: var(--p-bg); z-index: 5; }
|
|
52
|
+
.em-tabs button:not(.em-toggle) { background: none; color: var(--p-muted); border: 0; border-bottom: 2px solid transparent; border-radius: 0; padding: 8px 12px 10px; margin-bottom: -1px; cursor: pointer; font: 500 13px -apple-system, BlinkMacSystemFont, system-ui, sans-serif; text-transform: capitalize; transition: color .15s, border-color .15s; }
|
|
53
|
+
.em-tabs button:not(.em-toggle):hover { color: var(--p-text); }
|
|
54
|
+
.em-tabs button.em-active { color: #fff; border-bottom-color: var(--p-accent); }
|
|
55
|
+
.em-btn { background: var(--p-raised, #1d283c); color: #e7eaf0; border: 1px solid #2c3a55; border-radius: 7px; padding: 5px 11px; cursor: pointer; font: 500 12px -apple-system, BlinkMacSystemFont, system-ui, sans-serif; transition: background .15s, border-color .15s; }
|
|
56
|
+
.em-btn:hover { background: #26344e; border-color: #3a4b6b; }
|
|
57
|
+
.em-btn.em-on { background: #f97316; border-color: #f97316; color: #1a0f05; }
|
|
58
|
+
.em-btn.em-ghost { background: transparent; border-color: transparent; color: #8a95a9; padding: 5px 8px; } .em-btn.em-ghost:hover { background: #1d283c; color: #e7eaf0; }
|
|
59
|
+
.em-btn.em-icon { width: 30px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; }
|
|
60
|
+
.em-ev { background: var(--p-surface, #161f30); border: 1px solid var(--p-line, #232f45); border-left: 3px solid #f97316; border-radius: 10px; padding: 10px 12px; margin: 8px 0; font-size: 12px; color: #e7eaf0; }
|
|
61
|
+
.em-ev b { color: #fdba74; font-weight: 600; }
|
|
62
|
+
.em-ev-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; cursor: pointer; }
|
|
63
|
+
.em-ev-pos { font: 600 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: #8a95a9; background: #0e1522; border-radius: 5px; padding: 1px 6px; }
|
|
64
|
+
.em-ev-type { font-weight: 600; font-size: 13px; color: #fdba74; }
|
|
65
|
+
.em-ev-from { color: #8a95a9; font-size: 12px; }
|
|
66
|
+
.em-ev-time { margin-left: auto; color: #6b7690; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
67
|
+
.em-kv { display: grid; grid-template-columns: minmax(70px, max-content) 1fr; gap: 3px 14px; margin: 8px 0 2px; padding-top: 8px; border-top: 1px solid var(--p-line, #232f45); }
|
|
68
|
+
.em-kv dt { color: #8a95a9; margin: 0; } .em-kv dd { margin: 0; word-break: break-word; color: #e7eaf0; } .em-kv dd.em-none { color: #4f5b73; }
|
|
69
|
+
.em-kv dd a { color: #e7eaf0; text-decoration: none; border-bottom: 1px dotted #4f5b73; } .em-kv dd a:hover { color: #fdba74; border-color: #fdba74; }
|
|
70
|
+
.em-ev-foot { margin-top: 10px; }
|
|
71
|
+
.em-pill { display: inline-flex; align-items: center; gap: 4px; font: 600 11px -apple-system, system-ui, sans-serif; padding: 1px 8px; border-radius: 999px; background: #1d283c; color: #8a95a9; }
|
|
72
|
+
.em-pill.em-pass { background: rgba(34,197,94,.14); color: #4ade80; } .em-pill.em-fail { background: rgba(239,68,68,.15); color: #f87171; }
|
|
73
|
+
.em-spec-title { font-weight: 600; font-size: 13px; color: #e7eaf0; line-height: 1.35; }
|
|
74
|
+
.em-spec-meta { color: #8a95a9; font-size: 12px; margin-top: 2px; }
|
|
75
|
+
.em-chev { display: inline-block; width: 14px; color: #6b7690; font-size: 10px; }
|
|
54
76
|
.em-ev pre { margin: 4px 0 0; padding: 0; background: transparent; white-space: pre-wrap; word-break: break-word; color: #d1d5db; font-size: 11px; } /* background set explicitly: a screen framework (Bulma) styles bare <pre> light, which left this text unreadable */
|
|
55
77
|
.em-slider { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 12px; color: #9ca3af; } .em-slider .em-slider-text { min-width: 120px; text-align: right; }
|
|
56
78
|
/* `.em-panel input` (below) makes every panel input a full-width text box: the slider and the checkbox undo that, with a selector that beats it. */
|
|
@@ -59,8 +81,11 @@
|
|
|
59
81
|
.em-hl { outline: 3px solid #f97316 !important; outline-offset: 2px; box-shadow: 0 0 0 6px rgba(249, 115, 22, 0.25) !important; }
|
|
60
82
|
.em-ev.em-cmd { border-left-color: #3b82f6; } .em-ev.em-cmd b { color: #93c5fd; }
|
|
61
83
|
.em-ev.em-fail { border-left-color: #ef4444; } .em-ev.em-pass { border-left-color: #22c55e; }
|
|
84
|
+
.em-ev.em-pass:hover, .em-ev.em-fail:hover { border-color: #2c3a55; }
|
|
62
85
|
.em-ev.em-out { border-left-color: #14b8a6; }
|
|
63
|
-
.em-panel input, .em-panel textarea { width: 100%; box-sizing: border-box; margin: 2px 0 6px; padding:
|
|
86
|
+
.em-panel input, .em-panel textarea { width: 100%; box-sizing: border-box; margin: 2px 0 6px; padding: 6px 9px; border-radius: 7px; border: 1px solid #2c3a55; background: #0e1522; color: #fff; font: inherit; }
|
|
87
|
+
.em-panel input:focus, .em-panel textarea:focus { outline: none; border-color: #f97316; }
|
|
88
|
+
.em-panel select { box-sizing: border-box; margin: 0; padding: 6px 9px; border-radius: 7px; border: 1px solid #2c3a55; background: #0e1522; color: #fff; font: inherit; } .em-panel .em-input { margin: 0; width: auto; }
|
|
64
89
|
.em-auto { max-width: 760px; }
|
|
65
90
|
.em-auto .em-ev { font-size: 13px; }
|
|
66
91
|
.em-auto h2 { color: #6b7280; }
|
|
@@ -309,7 +334,8 @@ function EventBrowser({ events }) {
|
|
|
309
334
|
<button className="em-btn" onClick={() => setFollow(null)}>×</button>
|
|
310
335
|
</div>
|
|
311
336
|
)}
|
|
312
|
-
<
|
|
337
|
+
<div className="em-sect" style={{ margin: '4px 0 0' }}><span className="em-pill">{shown.length === events.length ? `${events.length} event${events.length === 1 ? '' : 's'}` : `${shown.length} of ${events.length} events`}{withIx ? ` · ${recorded.length} interactions` : ''}</span></div>
|
|
338
|
+
{rows.length === 0 && <div className="em-empty">No events yet. Use the prototype and they appear here.</div>}
|
|
313
339
|
{[...rows].reverse().map((row) => {
|
|
314
340
|
if (row.kind === 'interaction') {
|
|
315
341
|
return (
|
|
@@ -326,35 +352,41 @@ function EventBrowser({ events }) {
|
|
|
326
352
|
const future = cursor != null && e.position > cursor;
|
|
327
353
|
return (
|
|
328
354
|
<div key={e.id} className="em-ev" style={future ? { opacity: 0.4 } : null}>
|
|
329
|
-
<div
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
{e.metadata.
|
|
333
|
-
{e.metadata.
|
|
334
|
-
<span
|
|
335
|
-
|
|
336
|
-
<div style={{ marginTop: 4 }}>
|
|
337
|
-
{Object.entries(e.data || {}).map(([k, v]) => (
|
|
338
|
-
<div key={k}>
|
|
339
|
-
<span style={{ color: '#9ca3af' }}>{k}: </span>
|
|
340
|
-
{v != null && typeof v === 'object' ? (
|
|
341
|
-
<code>{JSON.stringify(v)}</code>
|
|
342
|
-
) : (
|
|
343
|
-
<a href="#" title="Show every event carrying this value" style={{ color: '#e5e7eb' }}
|
|
344
|
-
onClick={(ev) => { ev.preventDefault(); setFollow({ field: k, value: v }); }}>
|
|
345
|
-
{String(v)}
|
|
346
|
-
</a>
|
|
347
|
-
)}
|
|
348
|
-
</div>
|
|
349
|
-
))}
|
|
355
|
+
<div className="em-ev-head" onClick={() => setOpen(isOpen ? null : e.id)}>
|
|
356
|
+
<span className="em-ev-pos">#{e.position}</span>
|
|
357
|
+
<span className="em-ev-type">{e.type}</span>
|
|
358
|
+
{e.metadata.command && <span className="em-ev-from">← {e.metadata.command}</span>}
|
|
359
|
+
{e.metadata.automation && <span className="em-pill">automation</span>}
|
|
360
|
+
{e.metadata.simulated && <span className="em-pill">simulated</span>}
|
|
361
|
+
<span className="em-ev-time">{new Date(e.metadata.at).toLocaleTimeString()}</span>
|
|
350
362
|
</div>
|
|
363
|
+
{Object.keys(e.data || {}).length > 0 && (
|
|
364
|
+
<dl className="em-kv">
|
|
365
|
+
{Object.entries(e.data || {}).flatMap(([k, v]) => {
|
|
366
|
+
const none = v === '' || v == null || v === undefined || v === 'undefined';
|
|
367
|
+
return [
|
|
368
|
+
<dt key={k + 't'}>{k}</dt>,
|
|
369
|
+
<dd key={k + 'd'} className={none ? 'em-none' : ''}>
|
|
370
|
+
{none ? '—' : v != null && typeof v === 'object' ? (
|
|
371
|
+
<code>{JSON.stringify(v)}</code>
|
|
372
|
+
) : (
|
|
373
|
+
<a href="#" title="Show every event carrying this value"
|
|
374
|
+
onClick={(ev) => { ev.preventDefault(); setFollow({ field: k, value: v }); }}>
|
|
375
|
+
{String(v)}
|
|
376
|
+
</a>
|
|
377
|
+
)}
|
|
378
|
+
</dd>,
|
|
379
|
+
];
|
|
380
|
+
})}
|
|
381
|
+
</dl>
|
|
382
|
+
)}
|
|
351
383
|
{isOpen && (
|
|
352
384
|
<pre>{JSON.stringify(
|
|
353
385
|
Object.assign({ id: e.id, at: e.metadata.at }, e.metadata.command && { command: e.metadata.command },
|
|
354
386
|
e.metadata.actor && { actor: e.metadata.actor }, e.metadata.automation && { automation: e.metadata.automation },
|
|
355
387
|
e.metadata.simulated && { simulated: true }), null, 1)}</pre>
|
|
356
388
|
)}
|
|
357
|
-
<button className="em-btn"
|
|
389
|
+
<div className="em-ev-foot"><button className="em-btn" onClick={() => rt.viewAsOf(e.position)}>View as of here</button></div>
|
|
358
390
|
</div>
|
|
359
391
|
);
|
|
360
392
|
})}
|
|
@@ -424,6 +456,48 @@ function SpecView({ spec }) {
|
|
|
424
456
|
);
|
|
425
457
|
}
|
|
426
458
|
|
|
459
|
+
// Named snapshots of the event log. The page has no storage of its own, so the player keeps them: this asks it for the list, and a save / load /
|
|
460
|
+
// delete is a message to it (see the contract at emMount). Outside the player nobody answers: `list` stays null and nothing is shown.
|
|
461
|
+
function Snapshots() {
|
|
462
|
+
const [list, setList] = useState(null);
|
|
463
|
+
const [name, setName] = useState('');
|
|
464
|
+
const [error, setError] = useState('');
|
|
465
|
+
const [picked, setPicked] = useState('');
|
|
466
|
+
React.useEffect(() => {
|
|
467
|
+
const onMsg = (e) => {
|
|
468
|
+
const d = e.data;
|
|
469
|
+
if (e.source !== window.parent || !d || d.source !== 'proto-host' || d.type !== 'snapshots') return;
|
|
470
|
+
setList(Array.isArray(d.list) ? d.list : []);
|
|
471
|
+
setError(d.error || '');
|
|
472
|
+
setPicked((p) => (Array.isArray(d.list) && d.list.some((s) => s.id === p) ? p : ''));
|
|
473
|
+
};
|
|
474
|
+
window.addEventListener('message', onMsg);
|
|
475
|
+
window.parent.postMessage({ source: 'proto-kit', type: 'snapshots-request' }, '*');
|
|
476
|
+
return () => window.removeEventListener('message', onMsg);
|
|
477
|
+
}, []);
|
|
478
|
+
if (list === null) return null;
|
|
479
|
+
const send = (msg) => window.parent.postMessage({ source: 'proto-kit', ...msg }, '*');
|
|
480
|
+
const save = () => { if (!name.trim()) return; send({ type: 'snapshot-save', name: name.trim(), events: rt.events }); setName(''); };
|
|
481
|
+
return (
|
|
482
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '0 0 10px' }}>
|
|
483
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
484
|
+
<select className="em-input" style={{ flex: 1, minWidth: 0 }} value={picked} onChange={(e) => setPicked(e.target.value)} aria-label="Snapshots">
|
|
485
|
+
<option value="">{list.length ? 'Snapshots…' : 'No snapshots yet'}</option>
|
|
486
|
+
{list.map((s) => <option key={s.id} value={s.id}>{s.name} · {s.count} event{s.count === 1 ? '' : 's'} · {new Date(s.at).toLocaleString()}</option>)}
|
|
487
|
+
</select>
|
|
488
|
+
<button className="em-btn" disabled={!picked} onClick={() => send({ type: 'snapshot-load', id: picked })} title="Replace the event log with this snapshot">Load</button>
|
|
489
|
+
<button className="em-btn" disabled={!picked} onClick={() => send({ type: 'snapshot-delete', id: picked })} title="Delete the selected snapshot" aria-label="Delete snapshot">Delete</button>
|
|
490
|
+
</div>
|
|
491
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
492
|
+
<input className="em-input" style={{ flex: 1, minWidth: 0 }} placeholder="Snapshot name" maxLength={60} value={name}
|
|
493
|
+
onChange={(e) => { setName(e.target.value); setError(''); }} onKeyDown={(e) => { if (e.key === 'Enter') save(); }} />
|
|
494
|
+
<button className="em-btn" disabled={!name.trim()} onClick={save} title="Save the current event log under this name">Save</button>
|
|
495
|
+
</div>
|
|
496
|
+
{error && <div style={{ color: '#f87171', fontSize: 12 }}>{error}</div>}
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
427
501
|
function Panel({ slices, open: panelOpen, onToggle }) {
|
|
428
502
|
const events = useLog();
|
|
429
503
|
const [tab, setTab] = useState('events');
|
|
@@ -467,8 +541,8 @@ function Panel({ slices, open: panelOpen, onToggle }) {
|
|
|
467
541
|
|
|
468
542
|
{tab === 'events' && (
|
|
469
543
|
<>
|
|
470
|
-
<h2>Event log</h2>
|
|
471
|
-
<
|
|
544
|
+
<div className="em-sect"><h2>Event log</h2><button className="em-btn" onClick={() => rt.reset()}>Reset</button></div>
|
|
545
|
+
<Snapshots />
|
|
472
546
|
<EventBrowser events={events} />
|
|
473
547
|
</>
|
|
474
548
|
)}
|
|
@@ -483,27 +557,31 @@ function Panel({ slices, open: panelOpen, onToggle }) {
|
|
|
483
557
|
|
|
484
558
|
{tab === 'specs' && (
|
|
485
559
|
<>
|
|
486
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
{allSpecs ? 'All screens' : 'This screen'}
|
|
490
|
-
|
|
491
|
-
</
|
|
492
|
-
|
|
560
|
+
<div className="em-sect">
|
|
561
|
+
<div>
|
|
562
|
+
<h2>Scenarios</h2>
|
|
563
|
+
{slices && <div className="em-sub">{allSpecs ? 'All screens' : 'This screen'} · {shownSpecs.length} of {specResults.length}</div>}
|
|
564
|
+
</div>
|
|
565
|
+
{slices && <button className="em-btn" onClick={() => setAllSpecs(!allSpecs)}>{allSpecs ? 'This screen only' : 'Show all'}</button>}
|
|
566
|
+
</div>
|
|
567
|
+
{shownSpecs.length === 0 && <div className="em-empty">No scenarios for this screen.</div>}
|
|
493
568
|
{shownSpecs.map(([r, i]) => (
|
|
494
|
-
<div key={i} className={'em-ev ' + (r.pass ? 'em-pass' : 'em-fail')} style={{ display: 'flex', alignItems: '
|
|
569
|
+
<div key={i} className={'em-ev ' + (r.pass ? 'em-pass' : 'em-fail')} style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
|
495
570
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
496
|
-
<div style={{ cursor: 'pointer' }} onClick={() => setOpen({ ...open, [i]: !open[i] })} title="Show or hide the scenario as text">
|
|
497
|
-
<span style={{
|
|
498
|
-
<
|
|
571
|
+
<div style={{ cursor: 'pointer', display: 'flex', gap: 6 }} onClick={() => setOpen({ ...open, [i]: !open[i] })} title="Show or hide the scenario as text">
|
|
572
|
+
<span className="em-chev" style={{ marginTop: 3 }}>{open[i] ? '▼' : '▶'}</span>
|
|
573
|
+
<div style={{ minWidth: 0 }}>
|
|
574
|
+
<div className="em-spec-title">{r.spec.title}</div>
|
|
575
|
+
<div className="em-spec-meta"><span className={'em-pill ' + (r.pass ? 'em-pass' : 'em-fail')}>{r.pass ? '✓ Passing' : '✗ Failing'}</span> <span style={{ marginLeft: 4 }}>{r.spec.slice}</span></div>
|
|
576
|
+
</div>
|
|
499
577
|
</div>
|
|
500
578
|
{open[i] && <SpecView spec={r.spec} />}
|
|
501
579
|
{!r.pass && <pre>{r.detail}</pre>}
|
|
502
580
|
</div>
|
|
503
|
-
<button className={'em-btn' + (armed === i ? ' em-on' : '')} style={{ flex: 'none' }} onClick={() => playGiven(r.spec, i)}
|
|
581
|
+
<button className={'em-btn em-icon' + (armed === i ? ' em-on' : '')} style={{ flex: 'none' }} onClick={() => playGiven(r.spec, i)}
|
|
504
582
|
title="Play the given events: the screens show this scenario's starting state">▶</button>
|
|
505
583
|
{r.spec.sliceId && (
|
|
506
|
-
<button className="em-btn" style={{ flex: 'none' }} onClick={() => EMRuntime.zoomToSlice(r.spec.sliceId)}
|
|
584
|
+
<button className="em-btn em-icon" style={{ flex: 'none' }} onClick={() => EMRuntime.zoomToSlice(r.spec.sliceId)}
|
|
507
585
|
title="Show this scenario's slice on the board"><span aria-hidden>◎</span></button>
|
|
508
586
|
)}
|
|
509
587
|
</div>
|
|
@@ -614,6 +692,8 @@ function App() {
|
|
|
614
692
|
* The message contract with the board's player (feedback + replay). Only the embedding page may talk to it; everything else is ignored.
|
|
615
693
|
* player → page { source: 'proto-host', type: 'events-request', id } → { source: 'proto-kit', type: 'events', id, events, cursor }
|
|
616
694
|
* { source: 'proto-host', type: 'load', events } replace the log (a replay starts from a recorded one)
|
|
695
|
+
* snapshots page → player { source: 'proto-kit', type: 'snapshots-request' | 'snapshot-save' {name, events} | 'snapshot-load' {id} | 'snapshot-delete' {id} }
|
|
696
|
+
* player → page { source: 'proto-host', type: 'snapshots', list: [{id, name, at, count}], error? } — the Event log panel's list; a load arrives as `load`
|
|
617
697
|
* { source: 'proto-host', type: 'view', position } show the state as of event #position (null = now)
|
|
618
698
|
* { source: 'proto-host', type: 'screen', title } switch to that screen's tab
|
|
619
699
|
* page → player { source: 'proto-kit', type: 'ready' } once mounted
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* <out>/index.html — so a fresh session or folder still rebuilds only what changed), copies the kept blocks to
|
|
12
12
|
* <out>/.work/slices/<id>.html and writes <out>/.work/status.json. Prints which slices are new,
|
|
13
13
|
* changed, unchanged or removed. Only new and changed slices need a new block.
|
|
14
|
+
* For every new/changed slice it also writes a block skeleton to <out>/.work/slices/<id>.html (generated specs from the
|
|
15
|
+
* scenarios and storylines, HTML screens converted to JSX, a defineSlice stub with @@TODO where judgment is needed),
|
|
16
|
+
* and the slice's data to <out>/.work/data/<id>.json.
|
|
14
17
|
* assemble writes <out>/index.html from the template, runtime.js and every block in .work/slices that
|
|
15
18
|
* belongs to a current slice, in timeline order.
|
|
16
19
|
* upload sends <out>/index.html to the platform (POST .../prototype/<context id>), which sanitizes, gzips
|
|
@@ -22,6 +25,7 @@ import { createHash } from 'node:crypto';
|
|
|
22
25
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
26
|
import { dirname, join } from 'node:path';
|
|
24
27
|
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { skeleton } from './scaffold.mjs';
|
|
25
29
|
|
|
26
30
|
const ASSETS = dirname(fileURLToPath(import.meta.url));
|
|
27
31
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -139,13 +143,25 @@ async function status() {
|
|
|
139
143
|
rmSync(BLOCKS, { recursive: true, force: true });
|
|
140
144
|
mkdirSync(BLOCKS, { recursive: true });
|
|
141
145
|
|
|
146
|
+
mkdirSync(join(WORK, 'data'), { recursive: true });
|
|
147
|
+
mkdirSync(join(WORK, 'previous'), { recursive: true });
|
|
142
148
|
const frameworkVotes = {};
|
|
149
|
+
const generated = {};
|
|
143
150
|
const slices = (fields.slices || []).map((slice, order) => {
|
|
144
151
|
const own = (slice.screens || []).map((x) => screens[x.linkedId || x.id] || null);
|
|
145
152
|
for (const s of own) if (s && s.pages) frameworkVotes[(s.cssFrameworks || [])[0] || 'bulma'] = (frameworkVotes[(s.cssFrameworks || [])[0] || 'bulma'] || 0) + 1;
|
|
146
153
|
const h = hash({ slice, specs: specsById[slice.id] || null, screens: own });
|
|
147
154
|
const before = byId[slice.id];
|
|
148
155
|
if (before && before.hash === h) writeFileSync(join(BLOCKS, `${slice.id}.html`), before.block + '\n');
|
|
156
|
+
const state = !before ? 'new' : before.hash === h ? 'unchanged' : 'changed';
|
|
157
|
+
if (state !== 'unchanged') {
|
|
158
|
+
const ownScreens = (slice.screens || []).map((x) => ({ title: x.title, lane: x.lane, ...(screens[x.linkedId || x.id] || {}) }));
|
|
159
|
+
writeFileSync(join(WORK, 'data', `${slice.id}.json`), JSON.stringify({ slice, specs: specsById[slice.id] || null, screens: ownScreens }, null, 2));
|
|
160
|
+
if (before) writeFileSync(join(WORK, 'previous', `${slice.id}.html`), before.block + '\n');
|
|
161
|
+
const sk = skeleton({ slice, hash: h, order: order + 1, specSlice: specsById[slice.id] || null, screens: ownScreens });
|
|
162
|
+
writeFileSync(join(BLOCKS, `${slice.id}.html`), sk.block);
|
|
163
|
+
generated[slice.id] = { specs: sk.specCount, needs: sk.needs };
|
|
164
|
+
}
|
|
149
165
|
return {
|
|
150
166
|
id: slice.id,
|
|
151
167
|
title: slice.title,
|
|
@@ -153,7 +169,7 @@ async function status() {
|
|
|
153
169
|
chapter: slice.chapter,
|
|
154
170
|
order: order + 1,
|
|
155
171
|
hash: h,
|
|
156
|
-
state
|
|
172
|
+
state,
|
|
157
173
|
};
|
|
158
174
|
});
|
|
159
175
|
const current = new Set(slices.map((s) => s.id));
|
|
@@ -169,13 +185,16 @@ async function status() {
|
|
|
169
185
|
}
|
|
170
186
|
if (!contextName && !contextIsId) contextName = args.context;
|
|
171
187
|
|
|
188
|
+
rmSync(join(WORK, 'questions.json'), { force: true }); // the questions are written afresh on every run (SKILL.md Step 5b), never carried over
|
|
189
|
+
|
|
172
190
|
const report = { context: contextName, contextId, baseline, cssFramework, mixedFrameworks: Object.keys(frameworkVotes).length > 1, slices, removed };
|
|
173
191
|
writeFileSync(join(WORK, 'status.json'), JSON.stringify(report, null, 2));
|
|
174
192
|
for (const s of slices) console.log(`${s.state.padEnd(9)} ${s.hash} ${s.id} ${s.title}`);
|
|
175
193
|
for (const r of removed) console.log(`removed ${''.padEnd(16)} ${r.id} ${r.title}`);
|
|
176
194
|
const todo = slices.filter((s) => s.state !== 'unchanged').length;
|
|
177
195
|
console.log(`\n${todo} of ${slices.length} slices to build, ${removed.length} removed (${{ platform: 'compared with the stored prototype', local: 'compared with the local page', none: 'no prototype yet' }[baseline]}) — see ${join(WORK, 'status.json')}`);
|
|
178
|
-
|
|
196
|
+
for (const s of slices.filter((x) => x.state !== 'unchanged')) console.log(`skeleton ${s.title}: ${generated[s.id].specs} spec(s) generated${generated[s.id].needs.length ? `, ${generated[s.id].needs.length} need(s) by hand` : ''} → ${join(BLOCKS, `${s.id}.html`)}`);
|
|
197
|
+
console.log(`Questions: this run writes ${join(WORK, 'questions.json')} afresh (SKILL.md Step 5b) and uploads — "nothing changed" does not skip the questions.`);
|
|
179
198
|
}
|
|
180
199
|
|
|
181
200
|
const CSS = {
|
|
@@ -198,6 +217,7 @@ function assemble() {
|
|
|
198
217
|
const [found] = blocksIn(block);
|
|
199
218
|
if (!found || found.id !== s.id) problems.push(`${file}: not a slice block for ${s.id}`);
|
|
200
219
|
else if (found.hash !== s.hash) problems.push(`${file}: data-em-hash is ${found.hash}, the slice's hash is ${s.hash}`);
|
|
220
|
+
if (block.includes('@@TODO')) problems.push(`${file}: still has @@TODO — fill every one`);
|
|
201
221
|
if ((block.match(/<\/script/gi) || []).length !== 1) problems.push(`${file}: a closing script tag appears inside the block`);
|
|
202
222
|
return block;
|
|
203
223
|
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Deterministic parts of a slice block — generated by `prototype.mjs status`, so the agent only writes what needs judgment
|
|
3
|
+
* (decide / evolve / process and wiring the screen to hooks). Shared by the platform skill and the local prototype.
|
|
4
|
+
*
|
|
5
|
+
* specsOf(slice, specs) the slice's `specs` array (every scenario and storyline, Step 4's table) as data
|
|
6
|
+
* htmlToJsx(html) one HTML page → JSX (class→className, style strings→objects, void elements closed, handlers dropped)
|
|
7
|
+
* skeleton(...) the block file: script tag with hash, GENERATED_SPECS, one component per HTML page, a defineSlice stub
|
|
8
|
+
*
|
|
9
|
+
* Anything the generator cannot decide is left as `@@TODO` — `assemble` refuses a block that still contains one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
13
|
+
const ATTR = {
|
|
14
|
+
class: 'className', for: 'htmlFor', tabindex: 'tabIndex', colspan: 'colSpan', rowspan: 'rowSpan', readonly: 'readOnly',
|
|
15
|
+
maxlength: 'maxLength', minlength: 'minLength', autocomplete: 'autoComplete', autofocus: 'autoFocus', contenteditable: 'contentEditable',
|
|
16
|
+
crossorigin: 'crossOrigin', enctype: 'encType', formaction: 'formAction', frameborder: 'frameBorder', srcset: 'srcSet',
|
|
17
|
+
cellpadding: 'cellPadding', cellspacing: 'cellSpacing', datetime: 'dateTime', novalidate: 'noValidate', spellcheck: 'spellCheck',
|
|
18
|
+
'accept-charset': 'acceptCharset', 'stroke-width': 'strokeWidth', 'stroke-linecap': 'strokeLinecap', 'stroke-linejoin': 'strokeLinejoin',
|
|
19
|
+
'fill-rule': 'fillRule', 'clip-rule': 'clipRule', 'clip-path': 'clipPath', viewbox: 'viewBox', 'stop-color': 'stopColor',
|
|
20
|
+
};
|
|
21
|
+
const BOOLEAN = new Set(['checked', 'disabled', 'selected', 'readonly', 'required', 'multiple', 'hidden', 'autofocus', 'open', 'novalidate']);
|
|
22
|
+
|
|
23
|
+
const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
24
|
+
const slug = (s) => String(s || '').replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => (c || '').toUpperCase()).replace(/^[^A-Za-z]+/, '') || 'Screen';
|
|
25
|
+
export const pascal = (s) => { const x = slug(s); return x.charAt(0).toUpperCase() + x.slice(1); };
|
|
26
|
+
|
|
27
|
+
function styleObject(css) {
|
|
28
|
+
const parts = css.split(';').map((d) => d.trim()).filter(Boolean).map((d) => {
|
|
29
|
+
const i = d.indexOf(':');
|
|
30
|
+
if (i < 0) return null;
|
|
31
|
+
const k = d.slice(0, i).trim();
|
|
32
|
+
const v = d.slice(i + 1).trim();
|
|
33
|
+
const key = k.startsWith('--') ? JSON.stringify(k) : camel(k.replace(/^-ms-/, 'ms-'));
|
|
34
|
+
return `${key}: ${JSON.stringify(v)}`;
|
|
35
|
+
}).filter(Boolean);
|
|
36
|
+
return `{{ ${parts.join(', ')} }}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function attrsToJsx(raw) {
|
|
40
|
+
const out = [];
|
|
41
|
+
const re = /([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
42
|
+
for (let m; (m = re.exec(raw)); ) {
|
|
43
|
+
const name = m[1];
|
|
44
|
+
const val = m[2] ?? m[3] ?? m[4];
|
|
45
|
+
const lower = name.toLowerCase();
|
|
46
|
+
if (lower.startsWith('on')) continue; // inline handlers: the page sanitizer strips them, the block wires its own
|
|
47
|
+
if (lower === 'style') { if (val) out.push(`style=${styleObject(val)}`); continue; }
|
|
48
|
+
const key = ATTR[lower] || (lower.startsWith('data-') || lower.startsWith('aria-') ? lower : /^[a-z]+$/.test(lower) ? lower : camel(name));
|
|
49
|
+
if (val === undefined) out.push(BOOLEAN.has(lower) ? `${key}` : `${key}={true}`);
|
|
50
|
+
else if (lower === 'value' || lower === 'checked') out.push(`${lower === 'value' ? 'defaultValue' : 'defaultChecked'}=${JSON.stringify(val)}`);
|
|
51
|
+
else out.push(`${key}=${JSON.stringify(val.replace(/"/g, '"').replace(/&/g, '&'))}`);
|
|
52
|
+
}
|
|
53
|
+
return out.length ? ' ' + out.join(' ') : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One HTML page (document or fragment) → a JSX expression body (single root). */
|
|
57
|
+
export function htmlToJsx(html) {
|
|
58
|
+
let src = String(html || '');
|
|
59
|
+
const body = src.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
60
|
+
if (body) src = body[1];
|
|
61
|
+
src = src.replace(/<!--[\s\S]*?-->/g, '').replace(/<script\b[\s\S]*?<\/script>/gi, '').replace(/<style\b[\s\S]*?<\/style>/gi, '');
|
|
62
|
+
const out = [];
|
|
63
|
+
const re = /<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)|(<)/g;
|
|
64
|
+
const stack = [];
|
|
65
|
+
for (let m; (m = re.exec(src)); ) {
|
|
66
|
+
if (m[5] !== undefined) {
|
|
67
|
+
out.push(m[5].replace(/[{}]/g, (c) => `{'${c}'}`).replace(/ /g, ' '));
|
|
68
|
+
} else if (m[6]) {
|
|
69
|
+
out.push('<');
|
|
70
|
+
} else if (m[1]) {
|
|
71
|
+
const tag = m[2].toLowerCase();
|
|
72
|
+
if (VOID.has(tag)) continue;
|
|
73
|
+
const at = stack.lastIndexOf(tag);
|
|
74
|
+
if (at < 0) continue;
|
|
75
|
+
while (stack.length > at) out.push(`</${stack.pop()}>`);
|
|
76
|
+
} else {
|
|
77
|
+
const tag = m[2].toLowerCase() === m[2] ? m[2] : m[2];
|
|
78
|
+
const lower = tag.toLowerCase();
|
|
79
|
+
const attrs = attrsToJsx(m[3] || '');
|
|
80
|
+
if (VOID.has(lower) || m[4]) out.push(`<${tag}${attrs} />`);
|
|
81
|
+
else { out.push(`<${tag}${attrs}>`); stack.push(lower === tag ? tag : tag); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
while (stack.length) out.push(`</${stack.pop()}>`);
|
|
85
|
+
return out.join('').trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── specs ───────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const kindOf = (step) => String(step.type || '').replace(/^SPEC_/, '').toUpperCase();
|
|
91
|
+
|
|
92
|
+
/** Typed value of a step field example: numbers/booleans stay, JSON-looking strings of lists/objects are parsed. */
|
|
93
|
+
function typed(v) {
|
|
94
|
+
if (typeof v !== 'string') return v;
|
|
95
|
+
const t = v.trim();
|
|
96
|
+
if (t === 'true' || t === 'false') return t === 'true';
|
|
97
|
+
if (t !== '' && /^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
98
|
+
if (/^[[{]/.test(t)) { try { return JSON.parse(t); } catch { /* keep the text */ } }
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const dataOf = (step) => Object.fromEntries((step.fields || []).filter((f) => f.name && f.example !== undefined && f.example !== '').map((f) => [f.name, typed(f.example)]));
|
|
103
|
+
const nameOf = (step) => String(step.title || '').replace(/\s+/g, '');
|
|
104
|
+
const eventOf = (step) => ({ type: nameOf(step), data: dataOf(step) });
|
|
105
|
+
|
|
106
|
+
/** The slice's specs: every GWT scenario and storyline as a spec entry (SKILL.md Step 4 table). `needs` lists what only a person/agent can add. */
|
|
107
|
+
export function specsOf(slice, specSlice) {
|
|
108
|
+
const specs = [];
|
|
109
|
+
const needs = [];
|
|
110
|
+
const automation = (slice.processors || [])[0];
|
|
111
|
+
for (const s of (specSlice && specSlice.specifications) || []) {
|
|
112
|
+
const then = s.then || [];
|
|
113
|
+
const given = (s.given || []).filter((x) => kindOf(x) === 'EVENT').map(eventOf);
|
|
114
|
+
const err = then.find((x) => kindOf(x) === 'ERROR');
|
|
115
|
+
const when = (s.when || []).find((x) => kindOf(x) === 'COMMAND');
|
|
116
|
+
const rm = then.find((x) => kindOf(x) === 'READMODEL') || (s.when || []).find((x) => kindOf(x) === 'READMODEL');
|
|
117
|
+
const cmds = then.filter((x) => kindOf(x) === 'COMMAND');
|
|
118
|
+
if (when && err) specs.push({ title: s.title, kind: 'command', given, when: { type: nameOf(when), data: dataOf(when) }, error: err.description || err.title });
|
|
119
|
+
else if (when) specs.push({ title: s.title, kind: 'command', given, when: { type: nameOf(when), data: dataOf(when) }, then: then.filter((x) => kindOf(x) === 'EVENT').map(eventOf) });
|
|
120
|
+
else if (rm) specs.push({ title: s.title, kind: 'readmodel', readModel: nameOf(rm), given, expect: s.expectEmptyList ? [] : Array.isArray(s.examples) ? s.examples.map((r) => (r && typeof r === 'object' ? Object.fromEntries(Object.entries(r).map(([k, v]) => [k, typed(v)])) : r)) : [] });
|
|
121
|
+
else if (cmds.length && automation) specs.push({ title: s.title, kind: 'automation', automation: String(automation.title).replace(/\s+/g, ''), given, expectCommands: cmds.map((c) => ({ type: nameOf(c), data: dataOf(c) })) });
|
|
122
|
+
else needs.push(`scenario "${s.title}" has no shape the generator knows — write its spec by hand`);
|
|
123
|
+
}
|
|
124
|
+
for (const sl of (specSlice && specSlice.storylines) || []) {
|
|
125
|
+
const given = [];
|
|
126
|
+
for (const beat of sl.elements || []) {
|
|
127
|
+
const k = kindOf(beat);
|
|
128
|
+
if (k === 'EVENT') given.push(eventOf(beat));
|
|
129
|
+
else if (k === 'COMMAND') specs.push({ title: `${sl.title}: ${beat.title}`, kind: 'command', given: [...given], when: { type: nameOf(beat), data: dataOf(beat) }, then: [] });
|
|
130
|
+
else if (k === 'READMODEL' && Array.isArray(beat.examples) && beat.examples.length) specs.push({ title: `${sl.title}: ${beat.title}`, kind: 'readmodel', readModel: nameOf(beat), given: [...given], expect: beat.examples });
|
|
131
|
+
}
|
|
132
|
+
if (!(sl.elements || []).length) needs.push(`storyline "${sl.title}" is empty`);
|
|
133
|
+
}
|
|
134
|
+
return { specs, needs };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── the block skeleton ──────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const lit = (v) => JSON.stringify(v, null, 2).replace(/\n/g, '\n');
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The block file of one flagged slice. `screens` = the slice's screen nodes ({ title, node }) from status; `keep` = the previous block's text
|
|
143
|
+
* for a changed slice is NOT used here — a changed slice's old block is kept by status and patched by the agent, skeletons are for new slices.
|
|
144
|
+
*/
|
|
145
|
+
export function skeleton({ slice, hash, order, specSlice, screens }) {
|
|
146
|
+
const { specs, needs } = specsOf(slice, specSlice);
|
|
147
|
+
const parts = [];
|
|
148
|
+
const todos = [];
|
|
149
|
+
const screenEntries = [];
|
|
150
|
+
(screens || []).forEach((s, i) => {
|
|
151
|
+
const comp = `Screen${pascal(s.title)}${i + 1}`;
|
|
152
|
+
if (s.pages && s.pages.length) {
|
|
153
|
+
const pageNames = s.pages.map((p, n) => {
|
|
154
|
+
const name = s.pages.length > 1 ? `${comp}Page${n + 1}` : comp;
|
|
155
|
+
const html = typeof p === 'string' ? p : p.html || p.content || '';
|
|
156
|
+
parts.push(`function ${name}() {\n return (\n <>\n${htmlToJsx(html)}\n </>\n );\n}`);
|
|
157
|
+
return name;
|
|
158
|
+
});
|
|
159
|
+
if (pageNames.length > 1) {
|
|
160
|
+
parts.push(`function ${comp}() {\n const pages = [${pageNames.join(', ')}];\n const [page, setPage] = React.useState(0);\n const Page = pages[page];\n return <Page next={() => setPage(Math.min(page + 1, pages.length - 1))} />;\n}`);
|
|
161
|
+
}
|
|
162
|
+
todos.push(`${comp}: markup is the whole page translated 1:1 — cut it to this slice's region (marks decide when the screen is shared), replace sample data with useReadModel / useCommand, wire the buttons`);
|
|
163
|
+
screenEntries.push(` ${JSON.stringify(s.title)}: { actor: ${JSON.stringify(s.lane || 'User')}, part: ${comp} },`);
|
|
164
|
+
} else {
|
|
165
|
+
todos.push(`screen "${s.title}" is a sketch or has no pages — write its part by hand (sketch elements are in .work/data/${slice.id}.json)`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
const cmdStubs = (slice.commands || []).filter((c) => !c.elementCopy).map((c) => ` ${String(c.title).replace(/\s+/g, '')}: { decide(history, cmd, ctx) { /* @@TODO */ } },`);
|
|
169
|
+
const rmStubs = (slice.readmodels || []).filter((c) => !c.elementCopy).map((c) => ` ${String(c.title).replace(/\s+/g, '')}: { initial: () => null, evolve(state, e) { /* @@TODO */ return state; } },`);
|
|
170
|
+
const autoStubs = (slice.processors || []).map((a) => ` ${String(a.title).replace(/\s+/g, '')}: { readModel: '@@TODO', trigger: 'auto', todo: (items) => items, process: (item) => ({ command: { type: '@@TODO', data: {} } }) },`);
|
|
171
|
+
const head = `<script type="text/plain" data-em-slice="${slice.id}" data-em-title="${String(slice.title).replace(/"/g, '"')}" data-em-hash="${hash}">`;
|
|
172
|
+
const body = [
|
|
173
|
+
`// GENERATED by prototype.mjs status — specs and screen markup. Edit in place and fill every open marker (assemble refuses a block that still has one).`,
|
|
174
|
+
...(needs.length ? needs.map((n) => `// NEEDS: ${n}`) : []),
|
|
175
|
+
...todos.map((t) => `// TODO: ${t}`),
|
|
176
|
+
`const GENERATED_SPECS = ${lit(specs)};`,
|
|
177
|
+
...parts,
|
|
178
|
+
[
|
|
179
|
+
`EMRuntime.defineSlice({`,
|
|
180
|
+
` id: ${JSON.stringify(slice.id)},`,
|
|
181
|
+
` title: ${JSON.stringify(slice.title)},`,
|
|
182
|
+
` order: ${order},`,
|
|
183
|
+
cmdStubs.length ? ` commands: {\n${cmdStubs.join('\n')}\n },` : '',
|
|
184
|
+
rmStubs.length ? ` readModels: {\n${rmStubs.join('\n')}\n },` : '',
|
|
185
|
+
autoStubs.length ? ` automations: {\n${autoStubs.join('\n')}\n },` : '',
|
|
186
|
+
` specs: GENERATED_SPECS,`,
|
|
187
|
+
screenEntries.length ? ` screens: {\n${screenEntries.join('\n')}\n },` : '',
|
|
188
|
+
`});`,
|
|
189
|
+
].filter(Boolean).join('\n'),
|
|
190
|
+
].filter(Boolean).join('\n\n');
|
|
191
|
+
return { block: `${head}\n${body}\n</script>\n`, specCount: specs.length, needs, todos };
|
|
192
|
+
}
|