eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– The app schema: one description, two runtimes

A mini-app's behaviour has always been specified twice: the JS app file (web/static/apps/todo.js) and, when native wants it, a Swift re-implementation β€” kept honest only by the event vectors. The app schema collapses that for a large class of apps: a single JSON file that formally describes how the app looks and behaves, interpreted by both runtimes:

  • JS engine + DOM renderer: web/static/schema-engine.js and schema-render.js (the app file wires schemaEvents/schemaRender into defineApp and keeps only the glue a schema doesn't cover: commands, sidebar preview/unread, chat wording, named parsers)
  • Swift engine + SwiftUI renderer: ios/LChatCore/Sources/LChatCore/SchemaEngine.swift and SchemaAppView.swift

Canonical schemas live in web/static/apps/schemas/<name>.json. The native package bundles byte-identical copies in ios/LChatCore/Sources/LChatCore/Schemas/ (SPM resources can't reach outside the package); test/schema-engine.test.mjs fails if the copies drift β€” after editing a schema, re-copy it:

cp web/static/apps/schemas/todo.json ios/LChatCore/Sources/LChatCore/Schemas/todo.json

The payoff: click "To-do" in the native app and the list renders with native components β€” SwiftUI list, sections, search field, swipe actions β€” assembled from the schema, not a webview. And because the schema is plain, readable JSON, an LLM can write or modify an app description as easily as a person can review one.

What the schema can (and can't) express

It deliberately covers one archetype well β€” the one most personal tools are:

  • a searchable, sectioned list folded from typed events,
  • actions that change state by emitting events (add / toggle / remove / dismiss), including server-side scheduling,
  • a detail ("show") view per item β€” definition lists, text blocks, buttons,
  • time presentation (relative + absolute), grace-period linger, empty states.

It is not a general UI language. A whiteboard, a poll's inline card, the chat feed itself β€” anything with free-form canvas, custom gestures, or bespoke layout β€” stays a hand-written app. When a schema can't say it, write code; don't grow the schema until a second real app needs the same thing.

The fold rules here are the same contract the event vectors pin (test/event-vectors/todo.json, remind.json): the vectors remain the spec, the schema is now the executable form of it, and both engines are tested against both.

A schema, top to bottom

{
  "schema": 1,
  "name": "todo",
  "title": "To-do",
  "glyph": "βœ“",
  "collection": "items",

  "state":     { … the data shape … },
  "events":    { … how events fold into state … },
  "partition": { … the grace-period buckets … },
  "actions":   { … what the user can do … },
  "views":     { "main": { … the list … }, "detail": { … the show view … } },
  "chat":      { … how events project into the Chat view … },
  "ticker":    { … optional per-item timed rules … }
}

collection names the primary list in state that views bind to.

Values, references, conditions, templates

Everything dynamic uses three tiny, LLM-friendly notations:

References β€” strings starting $, resolved against the evaluation scope: "$event.text", "$item.done", "$input" (the composer text, trimmed), "$now" (epoch ms, render/action time only β€” never in folds), "$newId" (a fresh id, minted once per action run), "$sid" (the last schedule step's result, "" on failure), "$parsed.when" (the last parse step's result), "$items" (the primary collection), "$buckets.active" / .grace / .archived. "$$x" escapes a literal leading dollar. Missing paths are null. The only computed values are {"add": [a, b]} and {"sub": [a, b]}.

Conditions β€” JSON objects, nestable:

{"truthy": "$item.done"}   {"not": …}   {"all": […]}   {"any": […]}
{"eq": [a, b]}   {"empty": "$items"}   {"gt"|"gte"|"lt"|"lte": [a, b]}

Templates β€” display strings interpolating {path} or {path|filter}: "Completed ({count})", "{item.when|reltime}". Filters: count (array length), reltime (in 2h / 5m ago / now), abstime (locale Wed, Jul 9, 14:30), number. {{ escapes a brace.

state β€” the data shape

"state": {
  "items": {
    "key": "id",
    "fields": { "id": "string", "text": "string", "done": "bool", "doneAt": "time", "by": "string" }
  }
}

Types: string, bool, number, time (epoch ms). Folds coerce every written field to its declared type, with missing/null becoming the zero value ("" / false / 0) β€” this is what makes "doneAt": "$event.ts" on an event with no ts fold to 0, exactly like the hand-written e.ts || 0. key is the identity field add dedupes on (Invariant #1 in apps/README.md).

events β€” the fold

The keys are the wire event types the app owns; each entry is a guard plus a list of declarative ops applied in order:

"todo.add": {
  "require": ["$event.text"],
  "fold": [
    { "add": { "to": "items", "item": { "id": "$event.id", "text": "$event.text",
               "done": false, "doneAt": 0, "by": "$event.name" } } }
  ]
}
  • require β€” every reference must be truthy or the event folds to a no-op (the reducer-validates-everything invariant: malformed or hostile events never crash or lie).
  • add β€” append unless the key already exists (idempotent).
  • update β€” { "in": "items", "id": "$event.id", "set": { … } }; a missing id is a no-op (a remind.fire racing a dismiss must land silently).
  • remove β€” { "from": "items", "id": "$event.id" }.
  • move β€” { "in": "items", "id": "$event.id", "after": "$event.after" }: reposition the item to immediately follow after in the collection array (""/missing = the array's start). A missing id, or an after id that no longer exists (removed concurrently), is a no-op.
  • sort β€” { "in": "items", "by": "when", "dir": "asc" }, stable on both runtimes.

Folds are pure and deterministic by construction: no $now, no randomness, no platform calls β€” two devices folding the same events converge, and snapshots stay correct.

partition β€” the grace-period buckets

"partition": { "flag": "done", "at": "doneAt", "graceMs": 750 }

Splits the collection into active (flag false), grace (flagged, younger than graceMs) and archived β€” derived from the clock at render time, never by a timer mutating state. A flagged item with no timestamp (legacy data) reads as long-archived. The renderer convention for grace rows is the Reminders-app completion flourish: the completed control fills immediately (checkbox goes accent), the row lingers in place for the window β€” still undoable β€” then tucks (animated) into the collapsible section. No countdown ring; the fill is the feedback.

actions β€” what the user can do

An action is a require guard plus steps, each optionally gated by if. The scope carries the trigger's bindings: {item} for a row action, {input} for the composer.

"add": {
  "require": ["$input"],
  "steps": [
    { "parse": { "kind": "reminder", "text": "$input",
        "onFail": { "hint": "When should I remind you? Try …" } } },
    { "schedule": { "at": "$parsed.when",
        "event": { "type": "remind.fire", "id": "$newId",
                   "notice": { "title": "⏰ Reminder", "body": "$parsed.text" } } } },
    { "emit": { "type": "remind.add", "id": "$newId", "text": "$parsed.text",
                "when": "$parsed.when", "sid": "$sid" } }
  ]
}

Steps: emit (encrypt + send an event), schedule (server-schedule a future event; sets $sid, "" on failure), unschedule, parse (run a named parser; failure aborts the action and shows the hint inline β€” never a floating toast). $newId is minted once per run, so the scheduled fire and the add that records its sid share an id. The host supplies the io surface (emit/schedule/unschedule/newId/parse/hint) β€” a schema never touches keys, tokens, or the network, same as any app.

Named parsers are the escape hatch for real parsing (natural-language time): each runtime implements them (parseReminder in remind.js; its Swift twin in SchemaParsers.swift), pinned by shared tests.

views.main β€” the list

"views": {
  "main": {
    "kind": "list",
    "composer": { "placeholder": "Add a to-do…", "button": "Add", "maxLength": 200, "action": "add" },
    "summary": [
      { "if": { "empty": "$items" }, "text": "No to-dos yet β€” add one above" },
      { "if": { "empty": "$buckets.active" }, "text": "all done πŸŽ‰" },
      { "text": "{buckets.active|count} left" }
    ],
    "search": { "fields": ["text"] },
    "sections": [
      { "id": "open", "source": ["active", "grace"], "order": { "by": "_index", "dir": "desc" },
        "reorder": { "action": "move" } },
      { "id": "completed", "source": "archived", "title": "Completed ({count})",
        "collapsible": true, "collapsed": true, "order": { "by": "doneAt", "dir": "desc" } }
    ],
    "empty": [ { "text": "No to-dos yet." } ],
    "row": { … },
    "refreshSeconds": 1
  }
}
  • composer β€” the add affordance; its action runs with $input bound. hint renders a helper line under it.
  • summary / empty β€” first-match-wins cases (if optional on the last). empty shows when every non-collapsible section has no rows, rendered as a quiet inline line on every platform (never a full-pane empty state β€” other sections may still have content).
  • search β€” substring match over the listed item fields, across sections.
  • sections β€” each draws from one or more partition buckets (source), ordered by an item field or _index (position in the collection β€” desc gives newest-first for append-ordered lists). collapsible sections render as a disclosure with a count and start collapsed if set. A reorder: { "action": name } makes the section's rows draggable: the drop runs the action with $item plus $after bound (the id of the item the moved one now follows in the collection array; "" = the start β€” the renderer maps the visual drop by the section's order direction, and suppresses reorder while a search query is active). Only meaningful on _index-ordered sections β€” a field-sorted section would immediately re-sort away the drop.
  • refreshSeconds β€” re-resolve rows on a tick (live relative times).

row describes one item:

"row": {
  "leading": { "kind": "check", "on": "$item.done", "action": "toggle" },
  "title": { "text": "{item.text}", "strike": { "truthy": "$item.done" }, "muted": { "truthy": "$item.done" } },
  "subtitle": [
    { "if": { "truthy": "$item.fired" }, "text": "Reminded Β· {item.firedAt|reltime}" },
    { "text": "{item.when|reltime}", "accent": { "gte": ["$now", "$item.when"] } }
  ],
  "note": [ { "if": …, "text": "{item.when|abstime}" } ],
  "trailing": [ { "kind": "button", "glyph": "Γ—", "label": "Remove", "role": "destructive", "action": "remove" } ],
  "tap": { "action": "toggle" }
}

subtitle and note are first-match-wins case lists (accent colours the line when its condition holds). tap is { "action": name }, { "detail": true } (navigate to the show view), or { "url": ref } β€” the row LINKS OUT to the resolved https URL (views, docs/views.md: each issue or headline opens where it lives). tap.url is web-only for now: the web renderer opens a new tab (noopener, scheme re-checked at click time; a row whose ref resolves to anything but https simply isn't tappable), and the native runtime ignores the key until it grows the same additive support. When a detail view exists but tap runs an action, native exposes Details via the row's context menu. Destructive trailing buttons double as swipe actions on iOS.

views.detail β€” the show view

The click-to-view-more surface: a title plus sections, each conditional β€” definitions (term/value rows), text (a body paragraph, article-style), and buttons (actions; dismiss: true pops the view after running).

"detail": {
  "title": "{item.text}",
  "sections": [
    { "kind": "definitions", "rows": [
      { "term": "When", "value": "{item.when|abstime}" },
      { "term": "Status", "value": "Upcoming", "if": { "not": { "truthy": "$item.fired" } } }
    ] },
    { "kind": "text", "body": "{item.body}", "if": { "truthy": "$item.body" } },
    { "kind": "buttons", "items": [
      { "label": "Dismiss reminder", "action": "dismiss", "role": "destructive", "dismiss": true }
    ] }
  ]
}

This is enough for the "list of articles" shape: a sorted, searchable list whose rows open into a sectioned show view.

chat β€” projection into the Chat view

The schema form of the chatMessage hook: per event type, a template (bot bubble) or "action": true (a /me-style line), with optional groupText for wording that must change when many people read it. Absent types are silent.

ticker β€” per-item timed rules

For behaviour that must happen while the pane is open (Reminders' client-side fallback firing when server scheduling failed):

"ticker": { "seconds": 1, "perItem": [
  { "once": true,
    "if": { "all": [ { "not": { "truthy": "$item.fired" } },
                     { "not": { "truthy": "$item.sid" } },
                     { "gte": ["$now", "$item.when"] } ] },
    "emit": { "type": "remind.fire", "id": "$item.id",
              "notice": { "title": "⏰ Reminder", "body": "$item.text" } } }
] }

once dedupes per item per session. The emitted event still goes through the normal fold, which must tolerate it like any other (idempotent, race-safe).

How native runs a schema

SchemaAppStore (in LChatCore) owns a thread's schema state: it seeds from the encrypted apps snapshot (GET /api/threads/{id}/state, decrypted with the thread key, folding only events newer than the snapshot's upTo), folds the decrypted app events the feed already sees, and applies live socket frames. Actions run against the existing native transport: emit β†’ ChatFeedModel.sendAppEventJSON (the same WS blob path chat uses), schedule/ unschedule β†’ LChatAPI.schedule/unschedule. SchemaAppView renders the resolved list/detail models with stock SwiftUI. Native reads snapshots but doesn't write them (the web clients keep doing that); an all-native household just folds the full window it has.

Where it plugs in: iOS ThreadListView.chatSurface and Mac NativeChatView.feed branch to the native surface when AppSchemaRegistry.schema(for: appId) returns one; every other app keeps the webview. On iOS the app thread gets a Chat | Interface toggle (the native feed is the Chat view β€” AppChatProjection already renders the event lines); on the Mac the existing Chat | Both | View modes stay, with the View pane native.

Keeping the contract (the checklist)

Changing a schema-driven app touches one JSON file β€” but verify all sides:

  1. Edit web/static/apps/schemas/<name>.json; copy to ios/LChatCore/Sources/LChatCore/Schemas/.
  2. npm test β€” vectors, JS/hand-written parity, list/action semantics, and the copy drift gate.
  3. iOS: xcodebuild test -scheme LChat -only-testing:LChatCoreTests β€” the Swift engine runs the same vectors and schema assertions.
  4. A behaviour change that alters the fold is a vector change: update test/event-vectors/<app>.json too, and fix both engines in the same PR β€” never fork the contract.

todo.js / remind.js themselves run on the schema: their events map is schemaEvents(schema) and their render is schemaRender(schema) β€” the web renderer (schema-render.js) consumes the same resolved list/row models SwiftUI does. The parity fuzz stays as the wiring's regression net (and goes back to twin-hunting if an app ever hand-writes a fold again).