eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– The app schema: one description, two runtimes
2
3A mini-app's behaviour has always been specified twice: the JS app file
4(`web/static/apps/todo.js`) and, when native wants it, a Swift re-implementation
5β€” kept honest only by the event vectors. The **app schema** collapses that for a
6large class of apps: a single JSON file that *formally describes how the app
7looks and behaves*, interpreted by both runtimes:
8
9- **JS engine + DOM renderer:** `web/static/schema-engine.js` and
10 `schema-render.js` (the app file wires `schemaEvents`/`schemaRender` into
11 defineApp and keeps only the glue a schema doesn't cover: commands, sidebar
12 preview/unread, chat wording, named parsers)
13- **Swift engine + SwiftUI renderer:** `ios/LChatCore/Sources/LChatCore/SchemaEngine.swift`
14 and `SchemaAppView.swift`
15
16Canonical schemas live in **`web/static/apps/schemas/<name>.json`**. The native
17package bundles byte-identical copies in
18`ios/LChatCore/Sources/LChatCore/Schemas/` (SPM resources can't reach outside
19the package); `test/schema-engine.test.mjs` fails if the copies drift β€” after
20editing a schema, re-copy it:
21
22```sh
23cp web/static/apps/schemas/todo.json ios/LChatCore/Sources/LChatCore/Schemas/todo.json
24```
25
26The payoff: **click "To-do" in the native app and the list renders with native
27components** β€” SwiftUI list, sections, search field, swipe actions β€” assembled
28from the schema, not a webview. And because the schema is plain, readable JSON,
29an LLM can write or modify an app description as easily as a person can review
30one.
31
32## What the schema can (and can't) express
33
34It deliberately covers one archetype well β€” the one most personal tools are:
35
36- a **searchable, sectioned list** folded from typed events,
37- **actions** that change state by emitting events (add / toggle / remove /
38 dismiss), including server-side scheduling,
39- a **detail ("show") view** per item β€” definition lists, text blocks, buttons,
40- time presentation (relative + absolute), grace-period linger, empty states.
41
42It is *not* a general UI language. A whiteboard, a poll's inline card, the chat
43feed itself β€” anything with free-form canvas, custom gestures, or bespoke
44layout β€” stays a hand-written app. When a schema can't say it, write code;
45don't grow the schema until a second real app needs the same thing.
46
47The fold rules here are the **same contract** the event vectors pin
48(`test/event-vectors/todo.json`, `remind.json`): the vectors remain the spec,
49the schema is now the *executable* form of it, and both engines are tested
50against both.
51
52## A schema, top to bottom
53
54```json
55{
56 "schema": 1,
57 "name": "todo",
58 "title": "To-do",
59 "glyph": "βœ“",
60 "collection": "items",
61
62 "state": { … the data shape … },
63 "events": { … how events fold into state … },
64 "partition": { … the grace-period buckets … },
65 "actions": { … what the user can do … },
66 "views": { "main": { … the list … }, "detail": { … the show view … } },
67 "chat": { … how events project into the Chat view … },
68 "ticker": { … optional per-item timed rules … }
69}
70```
71
72`collection` names the primary list in `state` that views bind to.
73
74### Values, references, conditions, templates
75
76Everything dynamic uses three tiny, LLM-friendly notations:
77
78**References** β€” strings starting `$`, resolved against the evaluation scope:
79`"$event.text"`, `"$item.done"`, `"$input"` (the composer text, trimmed),
80`"$now"` (epoch ms, render/action time only β€” never in folds), `"$newId"` (a
81fresh id, minted once per action run), `"$sid"` (the last schedule step's
82result, `""` on failure), `"$parsed.when"` (the last parse step's result),
83`"$items"` (the primary collection), `"$buckets.active"` / `.grace` /
84`.archived`. `"$$x"` escapes a literal leading dollar. Missing paths are null.
85The only computed values are `{"add": [a, b]}` and `{"sub": [a, b]}`.
86
87**Conditions** β€” JSON objects, nestable:
88
89```json
90{"truthy": "$item.done"} {"not": …} {"all": […]} {"any": […]}
91{"eq": [a, b]} {"empty": "$items"} {"gt"|"gte"|"lt"|"lte": [a, b]}
92```
93
94**Templates** β€” display strings interpolating `{path}` or `{path|filter}`:
95`"Completed ({count})"`, `"{item.when|reltime}"`. Filters: `count` (array
96length), `reltime` (`in 2h` / `5m ago` / `now`), `abstime` (locale
97`Wed, Jul 9, 14:30`), `number`. `{{` escapes a brace.
98
99### `state` β€” the data shape
100
101```json
102"state": {
103 "items": {
104 "key": "id",
105 "fields": { "id": "string", "text": "string", "done": "bool", "doneAt": "time", "by": "string" }
106 }
107}
108```
109
110Types: `string`, `bool`, `number`, `time` (epoch ms). Folds **coerce** every
111written field to its declared type, with missing/null becoming the zero value
112(`""` / `false` / `0`) β€” this is what makes `"doneAt": "$event.ts"` on an event
113with no `ts` fold to `0`, exactly like the hand-written `e.ts || 0`. `key` is
114the identity field `add` dedupes on (Invariant #1 in `apps/README.md`).
115
116### `events` β€” the fold
117
118The keys are the wire event types the app owns; each entry is a guard plus a
119list of declarative ops applied in order:
120
121```json
122"todo.add": {
123 "require": ["$event.text"],
124 "fold": [
125 { "add": { "to": "items", "item": { "id": "$event.id", "text": "$event.text",
126 "done": false, "doneAt": 0, "by": "$event.name" } } }
127 ]
128}
129```
130
131- `require` β€” every reference must be truthy or the event folds to a **no-op**
132 (the reducer-validates-everything invariant: malformed or hostile events
133 never crash or lie).
134- `add` β€” append unless the key already exists (idempotent).
135- `update` β€” `{ "in": "items", "id": "$event.id", "set": { … } }`; a missing id
136 is a no-op (a `remind.fire` racing a dismiss must land silently).
137- `remove` β€” `{ "from": "items", "id": "$event.id" }`.
138- `move` β€” `{ "in": "items", "id": "$event.id", "after": "$event.after" }`:
139 reposition the item to immediately follow `after` in the collection array
140 (`""`/missing = the array's start). A missing id, or an `after` id that no
141 longer exists (removed concurrently), is a no-op.
142- `sort` β€” `{ "in": "items", "by": "when", "dir": "asc" }`, stable on both
143 runtimes.
144
145Folds are pure and deterministic by construction: no `$now`, no randomness, no
146platform calls β€” two devices folding the same events converge, and snapshots
147stay correct.
148
149### `partition` β€” the grace-period buckets
150
151```json
152"partition": { "flag": "done", "at": "doneAt", "graceMs": 750 }
153```
154
155Splits the collection into `active` (flag false), `grace` (flagged, younger
156than `graceMs`) and `archived` β€” **derived from the clock at render time**,
157never by a timer mutating state. A flagged item with no timestamp (legacy
158data) reads as long-archived. The renderer convention for `grace` rows is the
159Reminders-app completion flourish: the completed control fills immediately
160(checkbox goes accent), the row lingers in place for the window β€” still
161undoable β€” then tucks (animated) into the collapsible section. No countdown
162ring; the fill is the feedback.
163
164### `actions` β€” what the user can do
165
166An action is a `require` guard plus steps, each optionally gated by `if`. The
167scope carries the trigger's bindings: `{item}` for a row action, `{input}` for
168the composer.
169
170```json
171"add": {
172 "require": ["$input"],
173 "steps": [
174 { "parse": { "kind": "reminder", "text": "$input",
175 "onFail": { "hint": "When should I remind you? Try …" } } },
176 { "schedule": { "at": "$parsed.when",
177 "event": { "type": "remind.fire", "id": "$newId",
178 "notice": { "title": "⏰ Reminder", "body": "$parsed.text" } } } },
179 { "emit": { "type": "remind.add", "id": "$newId", "text": "$parsed.text",
180 "when": "$parsed.when", "sid": "$sid" } }
181 ]
182}
183```
184
185Steps: `emit` (encrypt + send an event), `schedule` (server-schedule a future
186event; sets `$sid`, `""` on failure), `unschedule`, `parse` (run a named
187parser; failure aborts the action and shows the `hint` inline β€” never a
188floating toast). `$newId` is minted once per run, so the scheduled fire and
189the add that records its sid share an id. The host supplies the io surface
190(emit/schedule/unschedule/newId/parse/hint) β€” a schema never touches keys,
191tokens, or the network, same as any app.
192
193Named parsers are the escape hatch for real parsing (natural-language time):
194each runtime implements them (`parseReminder` in `remind.js`; its Swift twin in
195`SchemaParsers.swift`), pinned by shared tests.
196
197### `views.main` β€” the list
198
199```json
200"views": {
201 "main": {
202 "kind": "list",
203 "composer": { "placeholder": "Add a to-do…", "button": "Add", "maxLength": 200, "action": "add" },
204 "summary": [
205 { "if": { "empty": "$items" }, "text": "No to-dos yet β€” add one above" },
206 { "if": { "empty": "$buckets.active" }, "text": "all done πŸŽ‰" },
207 { "text": "{buckets.active|count} left" }
208 ],
209 "search": { "fields": ["text"] },
210 "sections": [
211 { "id": "open", "source": ["active", "grace"], "order": { "by": "_index", "dir": "desc" },
212 "reorder": { "action": "move" } },
213 { "id": "completed", "source": "archived", "title": "Completed ({count})",
214 "collapsible": true, "collapsed": true, "order": { "by": "doneAt", "dir": "desc" } }
215 ],
216 "empty": [ { "text": "No to-dos yet." } ],
217 "row": { … },
218 "refreshSeconds": 1
219 }
220}
221```
222
223- `composer` β€” the add affordance; its `action` runs with `$input` bound.
224 `hint` renders a helper line under it.
225- `summary` / `empty` β€” first-match-wins cases (`if` optional on the last).
226 `empty` shows when every non-collapsible section has no rows, rendered as a
227 quiet inline line on every platform (never a full-pane empty state β€” other
228 sections may still have content).
229- `search` β€” substring match over the listed item fields, across sections.
230- `sections` β€” each draws from one or more partition buckets (`source`),
231 ordered by an item field or `_index` (position in the collection β€” `desc`
232 gives newest-first for append-ordered lists). `collapsible` sections render
233 as a disclosure with a count and start `collapsed` if set. A
234 `reorder: { "action": name }` makes the section's rows draggable: the drop
235 runs the action with `$item` plus `$after` bound (the id of the item the
236 moved one now follows in the *collection array*; `""` = the start β€” the
237 renderer maps the visual drop by the section's order direction, and
238 suppresses reorder while a search query is active). Only meaningful on
239 `_index`-ordered sections β€” a field-sorted section would immediately re-sort
240 away the drop.
241- `refreshSeconds` β€” re-resolve rows on a tick (live relative times).
242
243**`row`** describes one item:
244
245```json
246"row": {
247 "leading": { "kind": "check", "on": "$item.done", "action": "toggle" },
248 "title": { "text": "{item.text}", "strike": { "truthy": "$item.done" }, "muted": { "truthy": "$item.done" } },
249 "subtitle": [
250 { "if": { "truthy": "$item.fired" }, "text": "Reminded Β· {item.firedAt|reltime}" },
251 { "text": "{item.when|reltime}", "accent": { "gte": ["$now", "$item.when"] } }
252 ],
253 "note": [ { "if": …, "text": "{item.when|abstime}" } ],
254 "trailing": [ { "kind": "button", "glyph": "Γ—", "label": "Remove", "role": "destructive", "action": "remove" } ],
255 "tap": { "action": "toggle" }
256}
257```
258
259`subtitle` and `note` are first-match-wins case lists (`accent` colours the
260line when its condition holds). `tap` is `{ "action": name }`,
261`{ "detail": true }` (navigate to the show view), or `{ "url": ref }` β€” the
262row LINKS OUT to the resolved https URL (views, docs/views.md: each issue or
263headline opens where it lives). `tap.url` is web-only for now: the web
264renderer opens a new tab (`noopener`, scheme re-checked at click time; a row
265whose ref resolves to anything but https simply isn't tappable), and the
266native runtime ignores the key until it grows the same additive support.
267When a `detail` view exists but tap runs an action, native exposes Details
268via the row's context menu. Destructive `trailing` buttons double as swipe
269actions on iOS.
270
271### `views.detail` β€” the show view
272
273The click-to-view-more surface: a title plus sections, each conditional β€”
274`definitions` (term/value rows), `text` (a body paragraph, article-style), and
275`buttons` (actions; `dismiss: true` pops the view after running).
276
277```json
278"detail": {
279 "title": "{item.text}",
280 "sections": [
281 { "kind": "definitions", "rows": [
282 { "term": "When", "value": "{item.when|abstime}" },
283 { "term": "Status", "value": "Upcoming", "if": { "not": { "truthy": "$item.fired" } } }
284 ] },
285 { "kind": "text", "body": "{item.body}", "if": { "truthy": "$item.body" } },
286 { "kind": "buttons", "items": [
287 { "label": "Dismiss reminder", "action": "dismiss", "role": "destructive", "dismiss": true }
288 ] }
289 ]
290}
291```
292
293This is enough for the "list of articles" shape: a sorted, searchable list
294whose rows open into a sectioned show view.
295
296### `chat` β€” projection into the Chat view
297
298The schema form of the `chatMessage` hook: per event type, a template (bot
299bubble) or `"action": true` (a `/me`-style line), with optional `groupText`
300for wording that must change when many people read it. Absent types are
301silent.
302
303### `ticker` β€” per-item timed rules
304
305For behaviour that must happen while the pane is open (Reminders' client-side
306fallback firing when server scheduling failed):
307
308```json
309"ticker": { "seconds": 1, "perItem": [
310 { "once": true,
311 "if": { "all": [ { "not": { "truthy": "$item.fired" } },
312 { "not": { "truthy": "$item.sid" } },
313 { "gte": ["$now", "$item.when"] } ] },
314 "emit": { "type": "remind.fire", "id": "$item.id",
315 "notice": { "title": "⏰ Reminder", "body": "$item.text" } } }
316] }
317```
318
319`once` dedupes per item per session. The emitted event still goes through the
320normal fold, which must tolerate it like any other (idempotent, race-safe).
321
322## How native runs a schema
323
324`SchemaAppStore` (in LChatCore) owns a thread's schema state: it seeds from the
325encrypted `apps` snapshot (`GET /api/threads/{id}/state`, decrypted with the
326thread key, folding only events newer than the snapshot's `upTo`), folds the
327decrypted app events the feed already sees, and applies live socket frames.
328Actions run against the existing native transport: `emit` β†’
329`ChatFeedModel.sendAppEventJSON` (the same WS blob path chat uses), `schedule`/
330`unschedule` β†’ `LChatAPI.schedule`/`unschedule`. `SchemaAppView` renders the
331resolved list/detail models with stock SwiftUI. Native **reads** snapshots but
332doesn't write them (the web clients keep doing that); an all-native household
333just folds the full window it has.
334
335Where it plugs in: iOS `ThreadListView.chatSurface` and Mac
336`NativeChatView.feed` branch to the native surface when
337`AppSchemaRegistry.schema(for: appId)` returns one; every other app keeps the
338webview. On iOS the app thread gets a `Chat | Interface` toggle (the native
339feed is the Chat view β€” `AppChatProjection` already renders the event lines);
340on the Mac the existing `Chat | Both | View` modes stay, with the View pane
341native.
342
343## Keeping the contract (the checklist)
344
345Changing a schema-driven app touches **one JSON file** β€” but verify all sides:
346
3471. Edit `web/static/apps/schemas/<name>.json`; copy to
348 `ios/LChatCore/Sources/LChatCore/Schemas/`.
3492. `npm test` β€” vectors, JS/hand-written parity, list/action semantics, and the
350 copy drift gate.
3513. iOS: `xcodebuild test -scheme LChat -only-testing:LChatCoreTests` β€” the
352 Swift engine runs the same vectors and schema assertions.
3534. A behaviour change that alters the fold is a **vector change**: update
354 `test/event-vectors/<app>.json` too, and fix both engines in the same PR β€”
355 never fork the contract.
356
357`todo.js` / `remind.js` themselves run on the schema: their events map is
358`schemaEvents(schema)` and their render is `schemaRender(schema)` β€” the web
359renderer (`schema-render.js`) consumes the same resolved list/row models
360SwiftUI does. The parity fuzz stays as the wiring's regression net (and goes
361back to twin-hunting if an app ever hand-writes a fold again).
362