@eventmodelers/cli 1.0.151 → 1.0.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/cli.js +21 -37
- package/lib/bash-guard.js +39 -1
- package/package.json +1 -1
- package/shared/build-kit/lib/adapters/pocketbase-realtime-adapter.js +14 -7
- package/shared/build-kit/lib/adapters/realtime-adapter.js +65 -2
- package/shared/build-kit/lib/adapters/supabase-realtime-adapter.js +19 -10
- package/shared/build-kit/lib/ralph.js +30 -62
- package/shared/skills/learn-eventmodelers-api/SKILL.md +30 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +51 -3
- package/stacks/modeling-kit/templates/.claude/skills/place-element/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +181 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/index.template.html +490 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +224 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/runtime.js +434 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/slices.example.html +201 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/references/api-fallback.md +30 -0
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +2 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{CONTEXT}}</title>
|
|
7
|
+
<!-- Libraries come from the board app's public/vendor — the page loads nothing from a public CDN. -->
|
|
8
|
+
<!-- @@CSS_FRAMEWORK@@ — exactly one, the one the context's screens use (see SKILL.md Step 6):
|
|
9
|
+
<link rel="stylesheet" href="/vendor/bulma-0.9.4.css" />
|
|
10
|
+
<link rel="stylesheet" href="/vendor/bootstrap-5.3.3.css" />
|
|
11
|
+
<script src="/vendor/tailwind-play-3.4.16.js"></script> -->
|
|
12
|
+
<script src="/vendor/react-18.3.1.production.min.js"></script>
|
|
13
|
+
<script src="/vendor/react-dom-18.3.1.production.min.js"></script>
|
|
14
|
+
<script src="/vendor/babel-standalone-7.24.7.min.js"></script>
|
|
15
|
+
<style>
|
|
16
|
+
/* Prototype chrome only — screens keep their own framework styling. Prefixed so nothing leaks. */
|
|
17
|
+
body { margin: 0; background: #f3f4f6; }
|
|
18
|
+
.em-shell { display: grid; grid-template-columns: 1fr 380px; min-height: 100vh; font: 14px/1.4 system-ui, sans-serif; }
|
|
19
|
+
.em-main { min-width: 0; display: flex; flex-direction: column; }
|
|
20
|
+
.em-bar { background: #111827; color: #e5e7eb; padding: 10px 16px 0; position: sticky; top: 0; z-index: 10; }
|
|
21
|
+
.em-bar h1 { font-size: 15px; margin: 0 0 8px; color: #fff; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
22
|
+
.em-bar select { background: #1f2937; color: #e5e7eb; border: 1px solid #374151; border-radius: 6px; padding: 2px 6px; font: 12px system-ui; }
|
|
23
|
+
.em-tabbar { display: flex; gap: 2px; overflow-x: auto; }
|
|
24
|
+
.em-tabbar button { background: #1f2937; color: #e5e7eb; border: 0; border-radius: 6px 6px 0 0; padding: 7px 12px; cursor: pointer; font: 13px system-ui; white-space: nowrap; }
|
|
25
|
+
.em-tabbar button.em-active { background: #f3f4f6; color: #111827; font-weight: 600; }
|
|
26
|
+
.em-tabbar .em-count { background: #f97316; color: #111827; border-radius: 9px; padding: 0 6px; margin-left: 6px; font-size: 11px; }
|
|
27
|
+
.em-stage { padding: 24px; }
|
|
28
|
+
.em-part { margin-bottom: 24px; }
|
|
29
|
+
.em-part-label { font: 600 11px system-ui; color: #6b7280; margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
|
|
30
|
+
.em-panel { background: #111827; color: #e5e7eb; padding: 12px; overflow-y: auto; max-height: 100vh; position: sticky; top: 0; font: 14px/1.4 system-ui, sans-serif; }
|
|
31
|
+
.em-panel h2, .em-auto h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #9ca3af; margin: 16px 0 6px; }
|
|
32
|
+
.em-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
33
|
+
.em-tabs button, .em-btn { background: #374151; color: #e5e7eb; border: 0; border-radius: 6px; padding: 5px 9px; cursor: pointer; font: 12px system-ui; }
|
|
34
|
+
.em-tabs button.em-active, .em-btn.em-on { background: #f97316; color: #111827; }
|
|
35
|
+
.em-ev { background: #1f2937; border-left: 4px solid #f97316; border-radius: 6px; padding: 6px 8px; margin: 6px 0; font-size: 12px; color: #e5e7eb; }
|
|
36
|
+
.em-ev b { color: #fdba74; }
|
|
37
|
+
.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 */
|
|
38
|
+
.em-ev.em-cmd { border-left-color: #3b82f6; } .em-ev.em-cmd b { color: #93c5fd; }
|
|
39
|
+
.em-ev.em-fail { border-left-color: #ef4444; } .em-ev.em-pass { border-left-color: #22c55e; }
|
|
40
|
+
.em-ev.em-out { border-left-color: #14b8a6; }
|
|
41
|
+
.em-panel input, .em-panel textarea { width: 100%; box-sizing: border-box; margin: 2px 0 6px; padding: 4px 6px; border-radius: 4px; border: 1px solid #4b5563; background: #111827; color: #fff; }
|
|
42
|
+
.em-auto { max-width: 760px; }
|
|
43
|
+
.em-auto .em-ev { font-size: 13px; }
|
|
44
|
+
.em-auto h2 { color: #6b7280; }
|
|
45
|
+
.em-toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: #111827; color: #fff; padding: 10px 16px; border-radius: 8px; font: 14px system-ui; z-index: 1000; }
|
|
46
|
+
.em-toast.em-error { background: #b91c1c; }
|
|
47
|
+
.em-banner { padding: 10px 16px; font: 14px system-ui; color: #fff; background: #b91c1c; }
|
|
48
|
+
.em-banner pre { margin: 6px 0 0; white-space: pre-wrap; font-size: 12px; }
|
|
49
|
+
@media (max-width: 1100px) { .em-shell { grid-template-columns: 1fr; } .em-panel { position: static; max-height: none; } }
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body data-em-context="{{CONTEXT}}">
|
|
53
|
+
<div id="em-banner"></div>
|
|
54
|
+
<div id="root"></div>
|
|
55
|
+
|
|
56
|
+
<!-- @@RUNTIME@@ — runtime.js from the skill's assets, pasted verbatim. -->
|
|
57
|
+
<script>
|
|
58
|
+
/* @@RUNTIME@@ */
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<!-- The prototype kit — hooks for the slice blocks and the chrome. Fixed, never generated. -->
|
|
62
|
+
<script type="text/plain" id="em-kit">
|
|
63
|
+
const { useState, useContext, createContext, useSyncExternalStore, Component } = React;
|
|
64
|
+
const Domain = EMRuntime.domain;
|
|
65
|
+
Domain.context = document.body.dataset.emContext;
|
|
66
|
+
let rt = null; // created once every slice has registered
|
|
67
|
+
const Proto = createContext(null);
|
|
68
|
+
|
|
69
|
+
// Bumped on every change (events, clock, automation runs, time travel).
|
|
70
|
+
let version = 0;
|
|
71
|
+
|
|
72
|
+
/** Re-render on every change to the runtime. */
|
|
73
|
+
function useLog() {
|
|
74
|
+
useSyncExternalStore(rt.subscribe, () => version);
|
|
75
|
+
return rt.events;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The current result of a read model, folded from the in-browser event log. */
|
|
79
|
+
function useReadModel(name, params) {
|
|
80
|
+
useLog();
|
|
81
|
+
return rt.query(name, params);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A command bound to a button: run(data) appends its events or shows the rule that rejected it. */
|
|
85
|
+
function useCommand(type) {
|
|
86
|
+
const { toast, actor } = useContext(Proto);
|
|
87
|
+
const [error, setError] = useState(null);
|
|
88
|
+
const run = (data) => {
|
|
89
|
+
const result = rt.execute(type, data, { actor });
|
|
90
|
+
setError(result.ok ? null : result.error);
|
|
91
|
+
toast(result.ok ? `${type} → ${result.events.map((e) => e.type).join(', ')}` : result.error, !result.ok);
|
|
92
|
+
return result;
|
|
93
|
+
};
|
|
94
|
+
return { run, error };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Navigation between tabs (screen titles); params carry selected ids from one screen into the next. */
|
|
98
|
+
function useNav() {
|
|
99
|
+
const { go, params } = useContext(Proto);
|
|
100
|
+
return { go, params };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** For a button the model gives no meaning to — kept, so the screen looks as designed, and flagged. */
|
|
104
|
+
function useNotModeled() {
|
|
105
|
+
const { toast } = useContext(Proto);
|
|
106
|
+
return (label) => toast(`"${label}" is not modeled on the board`, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The slice blocks use these as globals.
|
|
110
|
+
Object.assign(globalThis, { useReadModel, useCommand, useNav, useNotModeled });
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------------------------
|
|
113
|
+
// Chrome
|
|
114
|
+
|
|
115
|
+
/** A slice part that throws while rendering shows its error instead of taking the tab down. */
|
|
116
|
+
class PartBoundary extends Component {
|
|
117
|
+
constructor(props) {
|
|
118
|
+
super(props);
|
|
119
|
+
this.state = { error: null };
|
|
120
|
+
}
|
|
121
|
+
static getDerivedStateFromError(error) {
|
|
122
|
+
return { error };
|
|
123
|
+
}
|
|
124
|
+
render() {
|
|
125
|
+
if (!this.state.error) return this.props.children;
|
|
126
|
+
return (
|
|
127
|
+
<div className="em-banner">
|
|
128
|
+
Slice "{this.props.slice}" failed to render
|
|
129
|
+
<pre>{String((this.state.error && this.state.error.stack) || this.state.error)}</pre>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Screen tabs: one per screen title, holding every slice's part for that screen in timeline order. */
|
|
136
|
+
function screenTabs() {
|
|
137
|
+
const tabs = new Map();
|
|
138
|
+
for (const s of [...Domain.screens].sort((a, b) => a.order - b.order)) {
|
|
139
|
+
if (!tabs.has(s.title)) tabs.set(s.title, { title: s.title, actors: new Set(), parts: [] });
|
|
140
|
+
const tab = tabs.get(s.title);
|
|
141
|
+
if (s.actor) tab.actors.add(s.actor);
|
|
142
|
+
tab.parts.push(s);
|
|
143
|
+
}
|
|
144
|
+
return [...tabs.values()];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The automations tab: only automations whose todo list has open items show up. */
|
|
148
|
+
function AutomationsTab() {
|
|
149
|
+
useLog();
|
|
150
|
+
const due = Object.keys(Domain.automations).map((name) => ({ name, a: Domain.automations[name], todos: rt.todosOf(name) }));
|
|
151
|
+
const open = due.filter((d) => d.todos.length);
|
|
152
|
+
return (
|
|
153
|
+
<div className="em-auto">
|
|
154
|
+
<label>
|
|
155
|
+
<input type="checkbox" checked={rt.autoRun} onChange={(e) => rt.setAutoRun(e.target.checked)} /> run automatically when items appear
|
|
156
|
+
</label>
|
|
157
|
+
{open.length === 0 && <p>Nothing to do — no automation has an open item on its todo list.</p>}
|
|
158
|
+
{open.map(({ name, a, todos }) => (
|
|
159
|
+
<div key={name} className="em-ev em-cmd">
|
|
160
|
+
<b>{name}</b> <span>· {todos.length} open on {a.readModel}{a.external ? ' · calls the outside world' : ''}{a.trigger === 'schedule' ? ' · scheduled' : ''}</span>{' '}
|
|
161
|
+
<button className="em-btn em-on" onClick={() => rt.runAutomations('manual', name)}>Run</button>
|
|
162
|
+
<pre>{JSON.stringify(todos, null, 1)}</pre>
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
{rt.outbox.length > 0 && <h2>Outbox — stubbed calls to the outside world</h2>}
|
|
166
|
+
{rt.outbox.map((o, i) => (
|
|
167
|
+
<div key={i} className="em-ev em-out"><b>{o.automation}</b><pre>{o.effect}</pre></div>
|
|
168
|
+
))}
|
|
169
|
+
{rt.automationLog.length > 0 && <h2>Runs</h2>}
|
|
170
|
+
{rt.automationLog.map((r, i) => (
|
|
171
|
+
<div key={i} className={'em-ev ' + (r.ok ? 'em-pass' : 'em-fail')}>
|
|
172
|
+
<b>{r.automation}</b> → {r.command} {r.ok ? '✓' : '✗ ' + r.error}
|
|
173
|
+
</div>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when the event carries `value` anywhere in its data (e.g. every event of one order). */
|
|
180
|
+
function carries(e, value) {
|
|
181
|
+
return Object.values(e.data || {}).some((v) =>
|
|
182
|
+
v != null && typeof v === 'object' ? JSON.stringify(v).includes(JSON.stringify(value)) : String(v) === String(value),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The log, browsable: filter by event type, search, follow one value (click it) through every event
|
|
188
|
+
* carrying it, open an event for its details, and view the screens as of any event.
|
|
189
|
+
*/
|
|
190
|
+
function EventBrowser({ events }) {
|
|
191
|
+
const [types, setTypes] = useState([]);
|
|
192
|
+
const [search, setSearch] = useState('');
|
|
193
|
+
const [follow, setFollow] = useState(null);
|
|
194
|
+
const [open, setOpen] = useState(null);
|
|
195
|
+
const counts = events.reduce((m, e) => ((m[e.type] = (m[e.type] || 0) + 1), m), {});
|
|
196
|
+
const toggle = (t) => setTypes(types.includes(t) ? types.filter((x) => x !== t) : [...types, t]);
|
|
197
|
+
const needle = search.trim().toLowerCase();
|
|
198
|
+
const shown = events.filter(
|
|
199
|
+
(e) =>
|
|
200
|
+
(!types.length || types.includes(e.type)) &&
|
|
201
|
+
(!follow || carries(e, follow.value)) &&
|
|
202
|
+
(!needle || (e.type + ' ' + JSON.stringify(e.data) + ' ' + JSON.stringify(e.metadata)).toLowerCase().includes(needle)),
|
|
203
|
+
);
|
|
204
|
+
const cursor = rt.cursor;
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<>
|
|
208
|
+
{cursor != null && (
|
|
209
|
+
<div className="em-ev em-cmd">
|
|
210
|
+
<b>Viewing as of event #{cursor}</b> — screens show the state then; nothing can be recorded.{' '}
|
|
211
|
+
<button className="em-btn" onClick={() => rt.viewAsOf(null)}>Back to now</button>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
<input placeholder="Search events, data, commands…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
215
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
216
|
+
{Object.entries(counts).map(([t, n]) => (
|
|
217
|
+
<button key={t} className={'em-btn' + (types.includes(t) ? ' em-on' : '')} onClick={() => toggle(t)}>{t} · {n}</button>
|
|
218
|
+
))}
|
|
219
|
+
</div>
|
|
220
|
+
{follow && (
|
|
221
|
+
<div className="em-ev em-out">
|
|
222
|
+
Following <b>{follow.field} = {String(follow.value)}</b>{' '}
|
|
223
|
+
<button className="em-btn" onClick={() => setFollow(null)}>×</button>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
<h2>{shown.length === events.length ? `${events.length} events` : `${shown.length} of ${events.length} events`}</h2>
|
|
227
|
+
{[...shown].reverse().map((e) => {
|
|
228
|
+
const isOpen = open === e.id;
|
|
229
|
+
const future = cursor != null && e.position > cursor;
|
|
230
|
+
return (
|
|
231
|
+
<div key={e.id} className="em-ev" style={future ? { opacity: 0.4 } : null}>
|
|
232
|
+
<div style={{ cursor: 'pointer' }} onClick={() => setOpen(isOpen ? null : e.id)}>
|
|
233
|
+
<b>#{e.position} {e.type}</b>
|
|
234
|
+
{e.metadata.command && <span> ← {e.metadata.command}</span>}
|
|
235
|
+
{e.metadata.automation && <span> (automation)</span>}
|
|
236
|
+
{e.metadata.simulated && <span> (simulated)</span>}
|
|
237
|
+
<span style={{ float: 'right', color: '#9ca3af' }}>{new Date(e.metadata.at).toLocaleTimeString()}</span>
|
|
238
|
+
</div>
|
|
239
|
+
<div style={{ marginTop: 4 }}>
|
|
240
|
+
{Object.entries(e.data || {}).map(([k, v]) => (
|
|
241
|
+
<div key={k}>
|
|
242
|
+
<span style={{ color: '#9ca3af' }}>{k}: </span>
|
|
243
|
+
{v != null && typeof v === 'object' ? (
|
|
244
|
+
<code>{JSON.stringify(v)}</code>
|
|
245
|
+
) : (
|
|
246
|
+
<a href="#" title="Show every event carrying this value" style={{ color: '#e5e7eb' }}
|
|
247
|
+
onClick={(ev) => { ev.preventDefault(); setFollow({ field: k, value: v }); }}>
|
|
248
|
+
{String(v)}
|
|
249
|
+
</a>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
{isOpen && (
|
|
255
|
+
<pre>{JSON.stringify(
|
|
256
|
+
Object.assign({ id: e.id, at: e.metadata.at }, e.metadata.command && { command: e.metadata.command },
|
|
257
|
+
e.metadata.actor && { actor: e.metadata.actor }, e.metadata.automation && { automation: e.metadata.automation },
|
|
258
|
+
e.metadata.simulated && { simulated: true }), null, 1)}</pre>
|
|
259
|
+
)}
|
|
260
|
+
<button className="em-btn" style={{ marginTop: 4 }} onClick={() => rt.viewAsOf(e.position)}>View as of here</button>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
})}
|
|
264
|
+
</>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Replace the log with one copied earlier (from this prototype or another browser). */
|
|
269
|
+
function PasteLog({ onDone }) {
|
|
270
|
+
const [text, setText] = useState('');
|
|
271
|
+
const [error, setError] = useState(null);
|
|
272
|
+
const apply = () => {
|
|
273
|
+
try {
|
|
274
|
+
rt.importLog(JSON.parse(text));
|
|
275
|
+
onDone();
|
|
276
|
+
} catch (err) {
|
|
277
|
+
setError('That is not an event log: ' + err.message);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
return (
|
|
281
|
+
<div className="em-ev em-cmd">
|
|
282
|
+
<textarea rows={6} placeholder='Paste a log copied with "Copy log"' value={text} onChange={(e) => setText(e.target.value)} />
|
|
283
|
+
{error && <pre>{error}</pre>}
|
|
284
|
+
<button className="em-btn" onClick={apply}>Replace log</button>{' '}
|
|
285
|
+
<button className="em-btn" onClick={onDone}>Cancel</button>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function SimulateForm({ spec }) {
|
|
291
|
+
const [data, setData] = useState(() => Object.fromEntries((spec.fields || []).map((f) => [f.name, f.example ?? ''])));
|
|
292
|
+
return (
|
|
293
|
+
<div className="em-ev em-out">
|
|
294
|
+
<b>{spec.type}</b> {spec.from && <span>from {spec.from}</span>}
|
|
295
|
+
{(spec.fields || []).map((f) => (
|
|
296
|
+
<label key={f.name} style={{ display: 'block' }}>{f.name}
|
|
297
|
+
<input value={data[f.name]} onChange={(e) => setData({ ...data, [f.name]: e.target.value })} />
|
|
298
|
+
</label>
|
|
299
|
+
))}
|
|
300
|
+
<button className="em-btn" onClick={() => rt.simulate(spec.type, data)}>Simulate</button>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function Panel() {
|
|
306
|
+
const events = useLog();
|
|
307
|
+
const [tab, setTab] = useState('events');
|
|
308
|
+
const [specResults, setSpecResults] = useState(() => EMRuntime.runSpecs(Domain));
|
|
309
|
+
const [pasting, setPasting] = useState(false);
|
|
310
|
+
return (
|
|
311
|
+
<aside className="em-panel">
|
|
312
|
+
<div className="em-tabs">
|
|
313
|
+
{['events', 'simulate', 'specs', 'clock'].map((t) => (
|
|
314
|
+
<button key={t} className={tab === t ? 'em-active' : ''} onClick={() => setTab(t)}>{t}</button>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
{tab === 'events' && (
|
|
319
|
+
<>
|
|
320
|
+
<h2>Event log</h2>
|
|
321
|
+
<button className="em-btn" onClick={() => rt.reset()}>Reset</button>{' '}
|
|
322
|
+
<button className="em-btn" onClick={() => navigator.clipboard.writeText(JSON.stringify(rt.events, null, 2))}>Copy log</button>{' '}
|
|
323
|
+
<button className="em-btn" onClick={() => setPasting(true)}>Paste log</button>
|
|
324
|
+
{pasting && <PasteLog onDone={() => setPasting(false)} />}
|
|
325
|
+
{!rt.persistent && <p style={{ color: '#fbbf24' }}>This browser blocks storage here — the log lives until the page closes. Copy it to keep it.</p>}
|
|
326
|
+
<EventBrowser events={events} />
|
|
327
|
+
</>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{tab === 'simulate' && (
|
|
331
|
+
<>
|
|
332
|
+
<h2>Events from outside this context</h2>
|
|
333
|
+
{Domain.externalEvents.length === 0 && <p>None — everything this context reads, it records itself.</p>}
|
|
334
|
+
{Domain.externalEvents.map((x) => <SimulateForm key={x.type} spec={x} />)}
|
|
335
|
+
</>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{tab === 'specs' && (
|
|
339
|
+
<>
|
|
340
|
+
<h2>Scenarios from the board</h2>
|
|
341
|
+
<button className="em-btn" onClick={() => setSpecResults(EMRuntime.runSpecs(Domain))}>Run all</button>
|
|
342
|
+
{specResults.map((r, i) => (
|
|
343
|
+
<div key={i} className={'em-ev ' + (r.pass ? 'em-pass' : 'em-fail')}>
|
|
344
|
+
<b>{r.pass ? '✓' : '✗'} {r.spec.title}</b> <span>{r.spec.slice}</span>
|
|
345
|
+
{!r.pass && <pre>{r.detail}</pre>}
|
|
346
|
+
</div>
|
|
347
|
+
))}
|
|
348
|
+
</>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{tab === 'clock' && (
|
|
352
|
+
<>
|
|
353
|
+
<h2>Clock</h2>
|
|
354
|
+
<p>{rt.now().toLocaleString()}</p>
|
|
355
|
+
{[['+1 hour', 3600e3], ['+1 day', 864e5], ['+7 days', 7 * 864e5], ['+30 days', 30 * 864e5]].map(([l, ms]) => (
|
|
356
|
+
<button key={l} className="em-btn" style={{ marginRight: 4 }} onClick={() => rt.advanceClock(ms)}>{l}</button>
|
|
357
|
+
))}
|
|
358
|
+
<p style={{ color: '#9ca3af' }}>Items that fall due appear in the Automations tab.</p>
|
|
359
|
+
</>
|
|
360
|
+
)}
|
|
361
|
+
</aside>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const AUTOMATIONS = '__automations__';
|
|
366
|
+
|
|
367
|
+
function App() {
|
|
368
|
+
useLog();
|
|
369
|
+
const tabs = screenTabs();
|
|
370
|
+
const [current, setCurrent] = useState(() => ({ title: (tabs[0] || {}).title || AUTOMATIONS, params: {} }));
|
|
371
|
+
const [actor, setActor] = useState('');
|
|
372
|
+
const [toastMsg, setToastMsg] = useState(null);
|
|
373
|
+
const toast = (text, error) => {
|
|
374
|
+
setToastMsg({ text, error });
|
|
375
|
+
clearTimeout(toast.t);
|
|
376
|
+
toast.t = setTimeout(() => setToastMsg(null), 2800);
|
|
377
|
+
};
|
|
378
|
+
const go = (title, params) => setCurrent({ title, params: params || {} });
|
|
379
|
+
const actors = [...new Set(tabs.flatMap((t) => [...t.actors]))];
|
|
380
|
+
const visible = tabs.filter((t) => !actor || t.actors.has(actor));
|
|
381
|
+
const tab = tabs.find((t) => t.title === current.title);
|
|
382
|
+
const openTodos = Object.keys(Domain.automations).reduce((n, name) => n + rt.todosOf(name).length, 0);
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<Proto.Provider value={{ go, params: current.params, toast, actor: actor || null }}>
|
|
386
|
+
<div className="em-shell">
|
|
387
|
+
<div className="em-main">
|
|
388
|
+
<header className="em-bar">
|
|
389
|
+
<h1>
|
|
390
|
+
{Domain.context}
|
|
391
|
+
{actors.length > 1 && (
|
|
392
|
+
<select value={actor} onChange={(e) => setActor(e.target.value)} title="Acting as">
|
|
393
|
+
<option value="">every role</option>
|
|
394
|
+
{actors.map((a) => <option key={a} value={a}>{a}</option>)}
|
|
395
|
+
</select>
|
|
396
|
+
)}
|
|
397
|
+
{rt.cursor != null && <span style={{ color: '#93c5fd', fontSize: 12 }}>viewing as of event #{rt.cursor}</span>}
|
|
398
|
+
</h1>
|
|
399
|
+
<nav className="em-tabbar">
|
|
400
|
+
{visible.map((t) => (
|
|
401
|
+
<button key={t.title} className={t.title === current.title ? 'em-active' : ''} onClick={() => go(t.title)}>{t.title}</button>
|
|
402
|
+
))}
|
|
403
|
+
{Object.keys(Domain.automations).length > 0 && (
|
|
404
|
+
<button className={current.title === AUTOMATIONS ? 'em-active' : ''} onClick={() => go(AUTOMATIONS)}>
|
|
405
|
+
Automations{openTodos > 0 && <span className="em-count">{openTodos}</span>}
|
|
406
|
+
</button>
|
|
407
|
+
)}
|
|
408
|
+
</nav>
|
|
409
|
+
</header>
|
|
410
|
+
<main className="em-stage">
|
|
411
|
+
{current.title === AUTOMATIONS ? (
|
|
412
|
+
<AutomationsTab />
|
|
413
|
+
) : tab ? (
|
|
414
|
+
tab.parts.map((p) => (
|
|
415
|
+
<div key={p.slice} className="em-part">
|
|
416
|
+
{tab.parts.length > 1 && <div className="em-part-label">{p.slice}</div>}
|
|
417
|
+
<PartBoundary key={JSON.stringify(current.params)} slice={p.slice}>
|
|
418
|
+
<p.part params={current.params} />
|
|
419
|
+
</PartBoundary>
|
|
420
|
+
</div>
|
|
421
|
+
))
|
|
422
|
+
) : (
|
|
423
|
+
<p>This context has no screens — drive it from the Automations tab and the simulate panel.</p>
|
|
424
|
+
)}
|
|
425
|
+
</main>
|
|
426
|
+
</div>
|
|
427
|
+
<Panel />
|
|
428
|
+
</div>
|
|
429
|
+
{toastMsg && <div className={'em-toast' + (toastMsg.error ? ' em-error' : '')}>{toastMsg.text}</div>}
|
|
430
|
+
</Proto.Provider>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
globalThis.emMount = () => {
|
|
435
|
+
rt = EMRuntime.createRuntime(Domain);
|
|
436
|
+
rt.subscribe(() => version++);
|
|
437
|
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
438
|
+
};
|
|
439
|
+
</script>
|
|
440
|
+
|
|
441
|
+
<!-- ============================================================================================
|
|
442
|
+
@@SLICES@@ — one block per slice, inserted by `prototype.mjs assemble`: a text/plain script
|
|
443
|
+
carrying the slice's id, title and hash as data attributes, whose body calls EMRuntime.defineSlice.
|
|
444
|
+
A block is regenerated only when its slice's hash changed — the hash on the block is how the next
|
|
445
|
+
run knows. Contract: assets/slices.example.html.
|
|
446
|
+
============================================================================================ -->
|
|
447
|
+
|
|
448
|
+
<!-- Load: compile the kit, then every slice block on its own, run the scenarios, mount. A failure shows
|
|
449
|
+
as a red banner naming the slice, never as a blank page. -->
|
|
450
|
+
<script>
|
|
451
|
+
(function () {
|
|
452
|
+
const banner = document.getElementById('em-banner');
|
|
453
|
+
const show = (title, detail) => {
|
|
454
|
+
const box = document.createElement('div');
|
|
455
|
+
box.className = 'em-banner';
|
|
456
|
+
box.textContent = title;
|
|
457
|
+
if (detail) {
|
|
458
|
+
const pre = document.createElement('pre');
|
|
459
|
+
pre.textContent = detail;
|
|
460
|
+
box.appendChild(pre);
|
|
461
|
+
}
|
|
462
|
+
banner.appendChild(box);
|
|
463
|
+
};
|
|
464
|
+
const run = (src) => new Function(Babel.transform(src, { presets: ['react'] }).code)();
|
|
465
|
+
|
|
466
|
+
if (!globalThis.EMRuntime) return show('The prototype did not load', 'The runtime block failed to run — see the browser console.');
|
|
467
|
+
try {
|
|
468
|
+
run(document.getElementById('em-kit').textContent);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
return show('The prototype kit failed to start', err.message);
|
|
471
|
+
}
|
|
472
|
+
for (const block of document.querySelectorAll('script[data-em-slice]')) {
|
|
473
|
+
try {
|
|
474
|
+
run(block.textContent);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
show(`Slice "${block.dataset.emTitle}" does not load — the rest of the prototype works without it`, err.message);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const failed = EMRuntime.runSpecs(EMRuntime.domain).filter((r) => !r.pass);
|
|
480
|
+
if (failed.length) {
|
|
481
|
+
show(
|
|
482
|
+
`${failed.length} scenario${failed.length === 1 ? '' : 's'} from the board fail`,
|
|
483
|
+
failed.map((r) => `✗ [${r.spec.slice || '-'}] ${r.spec.title}\n ${r.detail}`).join('\n'),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
emMount();
|
|
487
|
+
})();
|
|
488
|
+
</script>
|
|
489
|
+
</body>
|
|
490
|
+
</html>
|