HOW TO ANSWER
Write a self-contained application that will be sealed into a DAI container: a single file holding the app, its SQLite database and its data, opened by double-clicking or from a link, with no server and no installation. There is no network: the container can reach nothing outside itself.
Decide the shape first (below). Then follow every constraint that applies to that shape. Each constraint has an ID in brackets, which is also what a lint finding cites; each says whether anything checks it. A constraint that nothing checks is still a constraint.
Content is the kit where the kit can express it: HTML with dai-rows, dai-value, dai-form, dai-attach and dai-save, and SQL in the document. The kit removes the dangerous sinks by construction (no statement built from a value, text-only rendering). Its write controls are for local tables only; shared tables are written in JavaScript through window.dai.replicated. Transport is whatever the channel has: when a tool is available, call it with the files as its arguments; when it is not, write the files as one text bundle, in the format under HOW TO HAND IT OVER.
FIRST, THE SHAPE
Answer these before writing a single table. The answer decides which tables are shared, whether there is a session profile, and which constraints apply.
1. Will anybody but the person who made this change its data — someone else, or the same person on another device, with both copies' changes kept?
Yes: It is passable or session. Ask the next question.
No: It is solo. If you are sending it out for other people to read, it is broadcast — which today is built exactly as solo.
2. Is it a closed group of a known size, where a copy forwarded to somebody else must not let them take part?
Yes: Session.
No: Passable.
SOLO — One person, one document.
Declare: No replicated tables.
What the runtime does: Ordinary SQLite. Whole copies replace each other when the file is saved; nothing merges.
For example: a packing list, a habit tracker, a recipe box.
PASSABLE — One person across their own devices, or a document handed around where every copy's changes are kept.
Declare: Tables marked -- dai:replicated, and no session profile.
What the runtime does: Rows are appended, never changed in place, and copies merge by union. There is no roster: anyone holding a copy participates.
For example: a shared receipt tracker, a household inventory, a trip plan two people edit.
SESSION — A closed group admitted by invite — today, two people: the creator and one invitee.
Declare: Tables marked -- dai:replicated, plus the profile line -- dai:profile session max_parties=N.
What the runtime does: Everything in passable, plus seats: the creator mints its own and one open seat, an invitee binds the open one, and only members' rows are read. A forwarded copy cannot join. There is no way yet to seat a third person, so a group larger than two cannot be a session.
For example: a game by message, a two-party agreement, anything turn-based.
BROADCAST — One publisher writes; recipients read.
Declare: Nothing. Mechanically it is solo.
What the runtime does: None yet. The separation between publisher and reader is a convention the application keeps, not something the runtime enforces: the confidentiality tier that makes it real is not implemented. A recipient's copy is an ordinary solo document they can write to.
For example: a statement, a report, a reference tool.
THE CONSTRAINTS
THE SHAPE, DECIDED FIRST
[SHAPE-FIRST] Decide the shape before writing a table
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Before writing schema.sql, decide which of the four shapes the application is — solo, passable, session or broadcast — using the two questions above, and state the decision in a comment at the top of schema.sql. The shape decides which tables are replicated, whether there is a session profile, and every rule below that applies only to shared tables.
Why: The shape is the decision every table depends on, and it cannot be fixed afterward by editing a table. The first chess application written against these instructions was never asked it, and it stored the board — derived state that is wrong the moment two copies merge.
[SHAPE-BROADCAST-CONVENTION] Broadcast has no runtime enforcement yet
Applies to: broadcast. Enforcement: not checked by anything — follow it anyway.
Build a broadcast application exactly as a solo one. Do not tell the reader their copy is read-only, locked, or protected: it is an ordinary document they can write to. If the application should not be edited by recipients, say that it is the publisher's, and put nothing in it that the publisher needs back.
Why: The mechanism that separates a publisher from its readers — the confidentiality levels — is not implemented. Claiming a protection the runtime does not provide is worse than saying nothing.
WHAT THE CONTAINER FORBIDS
[NO-NETWORK] Nothing is fetched
Applies to: every shape. Enforcement: checked by the lint (cdn-script, remote-stylesheet, network-call, remote-image, speculative-fetch); refused at run time.
Fetch nothing by URL. No CDN script tags: inline the library, or write the code without it. No hosted stylesheets or fonts: write the CSS inline and use system font stacks. No remote images: use inline SVG, a data: URI, or an emoji. No fetch, XMLHttpRequest, WebSocket, EventSource or sendBeacon, and no preconnect, dns-prefetch, prefetch or prerender links.
Why: The container permits no connections and the browser enforces it, so anything fetched fails silently and the application breaks in front of whoever opened it, far from the cause.
[STORE-IN-SQLITE] The database is the only storage
Applies to: every shape. Enforcement: checked by the lint (browser-storage).
Keep every piece of data in the SQLite database inside the document, opened with `await window.dai.openDatabase()`. Never use localStorage, sessionStorage, IndexedDB, cookies, the Cache API or the File System API.
Why: Browser storage belongs to the browser rather than to the file, so data kept there does not travel: send the document to somebody and it arrives empty.
[MODULE-FOR-AWAIT] A script that awaits is a module
Applies to: every shape. Enforcement: checked by the lint (await-in-classic-script).
Any at the end of the body. Reach for JavaScript only for what the kit cannot express. Do not write dai-kit.js yourself or put it in the bundle: the compiler adds it to every container.
Why: The kit removes the dangerous sinks by construction — no statement built from a value, text-only rendering — and does the querying, rendering and redrawing a hand-written application gets wrong.
[SHARED-KIT-READS] The kit reads shared tables; it does not write them
Applies to: passable, session. Enforcement: checked by the lint (shared-raw-write).
The kit's write controls — data-run, , — run plain SQL, so they are for local tables only. On a shared table they fail (SHARED-WRITE-SURFACE), and the kit neither catches the error nor shows it. The kit's reading elements, and , work over t_current views; redraw them on a merge with `window.daiKit.refresh()` (SHARED-REDRAW-ON-MERGE). Write shared rows in JavaScript through `window.dai.replicated`.
Why: Run against the rewrite: a kit INSERT into a replicated table fails with SQLite's NOT NULL error, and an UPDATE or DELETE with REPLICATED_TABLE_IMMUTABLE — uncaught, so the person sees nothing happen.
THE SCREEN AND THE CARD
[ICON-SVG] An icon that reads at 48 pixels
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Include icon.svg: a simple, bold mark on a square canvas (viewBox="0 0 100 100"), with a filled background, no text smaller than a third of the canvas, and no external references.
Why: It becomes the application's icon on a phone's home screen and in a browser tab.
[DESCRIBE-ON-CARD] One line, and three things it does
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
In the of index.html: — what it is for, under 60 characters, the way a store page puts a line under an app's name; and exactly three lines, each under 90 characters, starting with a verb, saying what somebody would tell a friend it does. Three, or none. Beside them, with the application's own background color.
Why: These are the whole of what a person sees on the card before they decide to open the document; two lines is a card with a gap in it.
[EDGE-TO-EDGE] Color to the edge, content inside it
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Paint the background to every edge of the screen, and push content clear of the strips a phone covers using the four custom properties the host sets: var(--dai-safe-top, 0px), var(--dai-safe-right, 0px), var(--dai-safe-bottom, 0px), var(--dai-safe-left, 0px). Usually that is padding at the top of what is first and at the bottom of what is last.
Why: Nothing is reserved for the host, so an application that ignores this puts its own title under the clock and its last row under the home indicator.
[TOP-RIGHT-CLEAR] Nothing tappable in the top right corner
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Leave the top right corner clear of anything tappable.
Why: The host floats one small round button there, over the application, and it is how a person reaches the menu.
[ONE-LAYOUT] One layout for every screen
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Build one layout that holds from about 320px wide to a wide desktop window: a single column that grows, sensible maximum widths on text, tap targets no smaller than 44px, no fixed pixel widths on anything that holds content. Check it at 390px and at 1280px. Do not ask which device it is for and do not build two.
Why: A document is sent as a link, and the sender does not choose whether it is opened on a phone, a tablet or a desktop.
[LOOK-FINISHED] Make it look finished
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Real spacing, a considered empty state, keyboard support, and a dark mode through prefers-color-scheme.
Why: It is a document somebody will keep.
HANDING IT OVER
[HANDOVER-BUNDLE] One bundle, or a tool call
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
When a tool is available, call it with the files as its arguments. Otherwise write the whole application as ONE fenced code block in the bundle format shown under HOW TO HAND IT OVER — one fence around every file, each file starting with a line "--- file: ". The bundle's second line is name: followed by the application's name, which becomes its title and file name. index.html is the entry point; other files are referenced from it by relative path.
Why: Outside a fence a chat window draws the file markers as dividing lines and breaks the application into pieces nobody can copy.
THE SURFACE
Everything an application is given, and nothing else.
Every application:
- await window.dai.openDatabase() — Opens the database inside this file. In a shared document it waits for the host's write rules first.
- db.exec(sql) — Runs one or more statements.
- db.exec({ sql, bind }) — Runs a statement with bound parameters. Omit bind when there are none: an empty array throws.
- db.selectObjects(sql, bind?) — Returns rows as plain objects. bind is an array for ? or an object for :name.
- db.selectValue(sql, bind?) — The first column of the first row — a count, a setting, a total.
- window.dai.autosaves — True under a host: every write is saved as it happens, and nothing needs pressing.
- await window.dai.saveDatabase(db) — Saves now. Needed only where there is no host (a file opened straight in a browser). Returns { saved, method }.
- window.dai.exportDatabase(db) — The database as bytes, without saving.
- window.dai.documentUuid — This document's identity.
- window.dai.signature — "valid", "unsigned" or "invalid" for this container.
- window.dai.onAppModeChange(fn) — Called when the container enters or leaves full-screen App Mode.
- window.dai.requestShare(session?) — Opens the host's own share sheet — the same one behind its menu. Does not share anything itself: the person still sees the card, still chooses whether to include their data, and still presses Send. In a session document, pass the session id to make it an invite into that one session: the copy that travels holds only that session's rows, and none of the other sessions or of this copy's local tables. Without one, the whole document is offered.
- window.daiKit.refresh() — Re-runs every kit query on the page. Call it in the dai:merged listener when the page uses or .
Shared tables (passable and session):
- await window.dai.openDatabase() — Opens the database inside this file. In a shared document it waits for the host's write rules first.
- db.exec(sql) — Runs one or more statements.
- db.exec({ sql, bind }) — Runs a statement with bound parameters. Omit bind when there are none: an empty array throws.
- db.selectObjects(sql, bind?) — Returns rows as plain objects. bind is an array for ? or an object for :name.
- db.selectValue(sql, bind?) — The first column of the first row — a count, a setting, a total.
- window.dai.autosaves — True under a host: every write is saved as it happens, and nothing needs pressing.
- await window.dai.saveDatabase(db) — Saves now. Needed only where there is no host (a file opened straight in a browser). Returns { saved, method }.
- window.dai.exportDatabase(db) — The database as bytes, without saving.
- window.dai.documentUuid — This document's identity.
- window.dai.signature — "valid", "unsigned" or "invalid" for this container.
- window.dai.onAppModeChange(fn) — Called when the container enters or leaves full-screen App Mode.
- window.dai.requestShare(session?) — Opens the host's own share sheet — the same one behind its menu. Does not share anything itself: the person still sees the card, still chooses whether to include their data, and still presses Send. In a session document, pass the session id to make it an invite into that one session: the copy that travels holds only that session's rows, and none of the other sessions or of this copy's local tables. Without one, the whole document is offered.
- window.dai.replicated.insert(table, values, session?) — Creates a shared row and returns its entity (32 hex characters). values is an object of your own columns. In a session document, session (hex) is required.
- window.dai.replicated.change(table, entity, values) — Writes a new version of a shared row, naming every current version as its parent — which is also how a conflict is resolved. values carries every one of your columns. Returns the entity.
- window.dai.replicated.remove(table, entity) — Deletes a shared row by writing a tombstone. The row leaves t_current. Returns the entity.
- window.addEventListener("dai:merged", fn) — Fired when another copy's rows arrive. event.detail: { applied, duplicate, rejected, newReplicas, conflicts, via } — via is "carrier" (a file or link was opened) or "mailbox" (rows arrived in the background).
- window.daiKit.refresh() — Re-runs every kit query on the page. Call it in the dai:merged listener when the page uses or .
Sessions:
- window.dai.replicated.session.create() — Starts a session: seats this copy and leaves one open seat. Returns { session, seat } as hex.
- window.dai.replicated.session.join(session, seat) — Binds this copy to an open seat. Call it when this copy opens an invite (SESSION-JOIN-ON-OPEN).
- window.dai.replicated.session.close(session) — Closes a session at what this copy has seen. Throws CLOSE_NOT_PERMITTED for a non-creator under close=creator.
- window.dai.replicated.session.reseat(session) — The creator's repair for a contested seat: replaces the open seat so a fresh invite can be taken. Throws NOT_SEAT_CREATOR for anyone else and CANNOT_RESEAT when no seat is contested.
THE SCHEMA
Markers:
- -- dai:replicated
Where: In schema.sql, directly above a CREATE TABLE, with only whitespace between.
Does: Makes that table replicated: append-only, merged by union, read through t_current.
- -- dai:profile session max_parties=N close=any|creator
Where: In schema.sql, any line comment (not inside a string or a block comment). close is optional and defaults to any.
Does: Makes the document a session document: every replicated row carries a session, and only members' rows are admitted.
What the rewrite creates, for a replicated table t:
- t_current (passable, session): One row per live entity: the latest version, with your columns, the _r_ columns, and _r_conflicted (1 when the entity has more than one current version). In a session document, only admitted rows. Read this for everything the application shows.
- t_conflicts (passable, session): One row per entity edited concurrently on two copies: _r_entity, heads (how many versions), head_ids. Read this to list what needs a person's decision.
- t_heads (passable, session): Every current version of every entity, including a deleted entity's tombstone. In a session document, only admitted rows. Read this only to show the competing versions of a conflicted entity.
- t (passable, session): Every row ever written, superseded and deleted ones included, and — in a session — non-member and late rows. Never read it for display or logic. Never write to it.
- _dai_replica (passable, session): This copy's own identity: id (16 bytes), seq, lc, label. One row once this copy has written anything or arrived from somebody else; empty in a brand-new document before its first write, so read it as possibly absent. SELECT lower(hex(id)) AS id FROM _dai_replica — this copy's replica id.
- _dai_seat_current (session): The seats the creator minted: seat, and _r_session. _r_replica is the creator. Who created a session, and which seats exist.
- _dai_binding_current (session): The seats joiners bound: seat, _r_session; _r_replica is the joiner. Which seat is open (minted, not bound) and which is contested (bound by more than one replica).
- _dai_member (session): session, replica: the replicas admitted to each session — each binds a minted seat that exactly one replica binds. Whether this copy may write in a session.
- _dai_close_current (session): The close of each closed session: one row per replica the closer had seen, with its highest seq. Whether a session is closed: any row for it.
REFUSALS YOU MAY MEET
What an error means, and what to do instead.
- REPLICATED_TABLE_IMMUTABLE — An UPDATE or DELETE ran against a replicated table. Write through window.dai.replicated.change or .remove instead (SHARED-WRITE-SURFACE).
- NOT NULL constraint failed: t._r_replica — A raw INSERT ran against a replicated table — by hand, from a kit control, or from a seed block. SQLite's own message, not a named refusal. Insert through window.dai.replicated.insert (SHARED-WRITE-SURFACE, SHARED-KIT-READS).
- ROW_REJECTED — The write rules refused a row. From an application, almost always an insert in a session document that did not name its session ("… carries no session, but
declares the session profile"); otherwise a raw UPDATE that tried to mark a superseded row current again. Pass the session as the third argument of insert (SESSION-ROW-CARRIES-SESSION), and never UPDATE a replicated table.
- WRITE_SURFACE_UNAVAILABLE — A shared write ran without the host's write rules — usually a document opened straight in a browser. Tell the person to open the document in the DAI opener (SHARED-NEEDS-HOST).
- NO_DOCUMENT_OPEN — A shared write ran before window.dai.openDatabase() resolved. Await openDatabase() before the first write.
- CLOSE_NOT_PERMITTED — A non-creator called session.close under close=creator. Offer the close control only to the creator (SESSION-CLOSE).
- NOT_SEAT_CREATOR — Someone other than the session's creator called session.reseat. Offer the fresh-invite repair only to the creator (SESSION-CONTESTED-SEAT).
- CANNOT_RESEAT — session.reseat was called on a session with no contested seat. Offer the repair only when a seat is contested (SESSION-CONTESTED-SEAT).
- REPLICATION_SCHEMA_INVALID — At build: a replicated table declared a PRIMARY KEY, AUTOINCREMENT or an _r_ column, or the session profile was malformed or had no replicated table. Fix schema.sql (SHARED-NO-KEY, SHARED-NO-R-COLUMNS, SESSION-PROFILE).
PATTERNS
The database, from JavaScript. Parameters are bound, never interpolated.
const db = await window.dai.openDatabase();
db.exec({ sql: "INSERT INTO notes (body) VALUES (?)", bind: ["Buy milk"] });
db.exec({ sql: "UPDATE notes SET done = :done WHERE id = :id", bind: { ":done": 1, ":id": 3 } });
const rows = db.selectObjects("SELECT * FROM notes ORDER BY id");
const one = db.selectObjects("SELECT * FROM notes WHERE id = ?", [3]);
const count = db.selectValue("SELECT count(*) FROM notes WHERE done = 0");
Pass bind only when there are parameters: an empty array is read as parameters promised and not supplied, and throws. Use SQL for the work — joins, aggregates, ORDER BY — rather than loading everything and filtering in JavaScript.
A local table and its seed (the notes table above is local, so an ordinary UPDATE is right for it):
--- file: schema.sql
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created TEXT NOT NULL DEFAULT (datetime('now'))
);
A migration, when a later version changes a table:
--- file: migrations/002-add-priority.sql
ALTER TABLE notes ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
The kit, over local tables:
left
Save
- A form's fields become the :parameters of its statement, by name.
- Inside a row, :parameters come from that row's columns; data-text writes a column as text; data-when shows an element when a column is truthy.
- A control's own attributes data-x become :x, and what was typed into an input is :typed.
- A picture goes in the document, in a BLOB column, so it travels with the file: Add a photo, and to show it. Never write a file path or a URL to an image the document does not carry.
A shared table, written and read:
--- file: schema.sql
-- dai:replicated
CREATE TABLE IF NOT EXISTS moves (
game_id TEXT NOT NULL, -- the games row's entity, as hex
ply INTEGER NOT NULL,
san TEXT NOT NULL
);
const entity = window.dai.replicated.insert("moves", { game_id, ply, san }); // add
window.dai.replicated.change("moves", entity, { game_id, ply, san: "Nf3" }); // every column
window.dai.replicated.remove("moves", entity); // delete
const moves = db.selectObjects(
"SELECT lower(hex(_r_entity)) AS entity, ply, san, _r_conflicted FROM moves_current WHERE game_id = ? ORDER BY ply",
[game_id]);
window.addEventListener("dai:merged", () => redraw());
HOW TO HAND IT OVER
When a tool is available, call it. If you can attach files to your answer, a zip of the files works too — a person drops it on the make-your-own page. Otherwise write the whole application as ONE fenced code block — open it with three backticks and the word text, close it with three backticks, and put every file inside it in this shape:
```text
dai bundle v1
name: Reading list
--- file: index.html
…
--- file: schema.sql
CREATE TABLE IF NOT EXISTS books (…);
--- file: app.js
const db = await window.dai.openDatabase();
--- file: icon.svg
```
One fence around everything, not one per file: the file markers begin with three dashes, and outside a fence a chat window draws them as dividing lines and breaks the application into pieces nobody can copy.
Every file starts with a line reading "--- file: " and its path, at the start of the line. Everything after that line belongs to that file until the next one. If a line inside a file would itself start with "--- file:", put a backslash in front of it.
COMPLETE EXAMPLES
One complete application per shape, each a bundle you could hand over as it is. Each schema.sql opens with the decision that led to its shape.
SOLO — one person, one document. Written with the kit and no JavaScript of its own; the seed rows are local and idempotent.
```text
dai bundle v1
name: Beach trip
--- file: schema.sql
-- Beach trip · shape: solo
--
-- The decision: one family's packing list, kept on one phone. Nobody else
-- adds to it and no second copy's changes need keeping, so nothing is shared:
-- ordinary tables, written with the kit, keys and all.
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
what TEXT NOT NULL,
packed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS trip (
id INTEGER PRIMARY KEY,
dates TEXT NOT NULL
);
--- file: index.html
Beach trip
Beach trip
of
packed
Beach
Clothes
Kids
--- file: app.css
/* Sea and sand. */
:root {
--paper: #f2f8fb;
--ink: #12283a;
--muted: #6b8494;
--line: #d6e5ee;
--accent: #0e7fb0;
--sand: #f5d9a8;
color-scheme: light;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--paper); color: var(--ink); }
body {
font: 16px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/*
* The screen's edges.
*
* A host draws this application edge to edge — under a status bar at the top
* and a home indicator at the bottom — and tells it how much of each edge is
* covered. The colour runs all the way out; the content is pushed clear of
* it. Zero on a screen with nothing in the way, which is what the fallback in
* each var() is for.
*/
main {
max-width: 560px;
margin: 0 auto;
padding: calc(28px + var(--dai-safe-top, 0px)) calc(20px + var(--dai-safe-right, 0px))
calc(40px + var(--dai-safe-bottom, 0px)) calc(20px + var(--dai-safe-left, 0px));
display: flex;
flex-direction: column;
gap: 20px;
}
header { display: flex; flex-direction: column; gap: 4px; }
.eyebrow {
margin: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
}
/*
* The dates are a control, and read as the line they replaced until touched.
* A form field sitting above the title would say "fill this in" to somebody
* who came here to pack a bag.
*/
input.eyebrow {
width: 100%;
padding: 2px 0;
border: 0;
border-bottom: 1px dashed transparent;
background: none;
font: inherit;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
}
input.eyebrow:hover {
border-bottom-color: var(--accent);
}
input.eyebrow:focus {
outline: none;
border-bottom-color: var(--accent);
}
h1 {
margin: 0;
font-size: 34px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
}
.progress {
margin: 6px 0 0;
display: inline-block;
align-self: flex-start;
padding: 5px 12px;
border-radius: 999px;
background: var(--sand);
color: #6b4b12;
font-size: 13px;
font-weight: 600;
}
h2 {
margin: 0 0 6px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
dai-rows {
display: block;
background: #fff;
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
}
.item {
display: grid;
grid-template-columns: 22px 1fr auto;
align-items: center;
gap: 12px;
padding: 11px 14px;
border-top: 1px solid var(--line);
cursor: pointer;
}
.item:first-child { border-top: 0; }
.item input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.box {
width: 22px;
height: 22px;
border: 2px solid var(--line);
border-radius: 999px;
}
.in {
display: none;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
.item:has(.in:not([hidden])) .box {
border-color: var(--accent);
background: var(--accent);
box-shadow: inset 0 0 0 3px #fff;
}
.item:has(.in:not([hidden])) .what { color: var(--muted); }
.item:has(.in:not([hidden])) .in { display: inline; }
dai-form { display: flex; gap: 8px; }
dai-form input,
dai-form select {
flex: 1;
min-width: 0;
padding: 10px 12px;
font: inherit;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: var(--ink);
}
dai-form select { flex: 0 0 auto; }
dai-form button,
dai-save {
padding: 10px 16px;
font: inherit;
font-weight: 600;
border: 0;
border-radius: 10px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
footer { display: flex; justify-content: flex-end; }
dai-save { display: inline-block; padding: 12px 22px; }
/*
* On a laptop, a card rather than a full-bleed page.
*
* Phones are the shape this was drawn for; on a wide screen the same layout
* stretched edge to edge and read as unfinished. Past a phone's width the
* page becomes a card on a quieter ground, at the width it was designed at.
*/
@media (min-width: 720px) {
html, body { background: #e3eef5; }
main {
margin: 40px auto;
padding: 32px 32px 36px;
background: var(--paper);
border-radius: 24px;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.04), 0 24px 60px -20px rgb(0 0 0 / 0.18);
}
}
--- file: icon.svg
```
PASSABLE — two people in one household hand the document back and forth. The shared table is written through window.dai.replicated and read from receipts_current; the totals are derived, never stored; a merge redraws; an edit made on both copies is shown and settled by the person.
```text
dai bundle v1
name: Receipts
--- file: schema.sql
-- Receipts · shape: passable
--
-- The decision: two people in one household both add the receipts they paid
-- for, each on their own phone, and hand the document back and forth. Both
-- copies' receipts must be kept, so the receipts table is shared. Anybody who
-- holds a copy may add to it — there is no fixed group to admit — so this is
-- passable, not a session.
--
-- What that costs, and why:
-- * receipts is append-only. It is written through window.dai.replicated
-- and read through receipts_current, never the table itself.
-- * No PRIMARY KEY, UNIQUE or CHECK on it. A receipt's identity is its
-- entity; two people entering the same receipt is theirs to notice.
-- * No stored total, no balance, no "settled" flag. What each person paid
-- and who owes whom are computed from the rows every time they are drawn;
-- a stored total would be wrong after the first merge.
-- * Which name this copy's person goes by is about this copy, not the
-- document, so it lives in a local table.
-- dai:replicated
CREATE TABLE IF NOT EXISTS receipts (
spent_on TEXT NOT NULL, -- the date on the receipt, YYYY-MM-DD, as entered
store TEXT NOT NULL,
cents INTEGER NOT NULL, -- the amount, in cents
paid_by TEXT NOT NULL -- the name of whoever paid
);
-- Local: never merged. May use keys and checks freely.
CREATE TABLE IF NOT EXISTS me (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL DEFAULT ''
);
--- file: index.html
Receipts
Opening…
Receipts
All receipts
No receipts yet. Add the first one above, then share this with whoever you split costs with.
--- file: app.js
// Receipts — a passable document. Shared rows are written through
// window.dai.replicated and read from receipts_current; totals are derived,
// never stored; a merge redraws; a conflict is shown, not decided silently.
const $ = (id) => document.getElementById(id);
let db; // opened by the start-up at the end of this file
const shared = window.dai.replicated;
/** The entity of the receipt being edited, or null when adding. */
let editing = null;
const rows = (sql, bind) => (bind === undefined ? db.selectObjects(sql) : db.selectObjects(sql, bind));
function myName() {
return rows("SELECT name FROM me WHERE id = 1")[0]?.name ?? "";
}
function money(cents) {
return (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD" });
}
function dayWords(ymd) {
const [y, m, d] = ymd.split("-").map(Number);
const day = new Date(y, m - 1, d);
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.round((today - day) / 86400000);
if (diff === 0) return "Today";
if (diff === 1) return "Yesterday";
return day.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
}
function todayYmd() {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function say(text) {
$("notice").textContent = text;
$("notice").hidden = !text;
}
/** Runs a shared write and says what went wrong, rather than failing silently. */
function write(fn) {
try {
fn();
say("");
return true;
} catch (error) {
const message = String(error?.message ?? error);
say(
message.startsWith("WRITE_SURFACE_UNAVAILABLE")
? "Open this document in the DAI opener to add or change receipts."
: `That did not save: ${message}`,
);
return false;
}
}
// ---- drawing ------------------------------------------------------------
function drawBalance() {
// Derived from the rows every time. Nothing here is stored.
const people = rows(
"SELECT paid_by, sum(cents) AS paid FROM receipts_current GROUP BY paid_by ORDER BY paid DESC",
);
const box = $("balance");
box.replaceChildren();
if (people.length === 0) return;
const total = people.reduce((sum, p) => sum + p.paid, 0);
const share = total / people.length;
const headline = document.createElement("p");
headline.className = "total";
headline.textContent = `${money(total)} spent`;
box.append(headline);
for (const person of people) {
const line = document.createElement("p");
const over = person.paid - share;
line.textContent =
people.length === 1
? `${person.paid_by} paid all of it`
: `${person.paid_by} paid ${money(person.paid)} · ${
Math.abs(over) < 1 ? "even" : over > 0 ? `is owed ${money(over)}` : `owes ${money(-over)}`
}`;
box.append(line);
}
}
function drawList() {
const list = $("list");
list.replaceChildren();
const receipts = rows(
`SELECT lower(hex(_r_entity)) AS entity, spent_on, store, cents, paid_by, _r_conflicted AS conflicted
FROM receipts_current
ORDER BY spent_on DESC, store`,
);
$("empty").hidden = receipts.length > 0;
for (const r of receipts) {
const item = document.createElement("li");
item.className = r.conflicted ? "receipt conflicted" : "receipt";
const main = document.createElement("div");
main.className = "what";
const store = document.createElement("strong");
store.textContent = r.store;
const meta = document.createElement("span");
meta.textContent = `${dayWords(r.spent_on)} · ${r.paid_by}`;
main.append(store, meta);
const amount = document.createElement("span");
amount.className = "amount";
amount.textContent = money(r.cents);
const actions = document.createElement("div");
actions.className = "row-actions";
if (r.conflicted) {
const choose = button("Changed twice — choose", () => openConflict(r.entity));
choose.className = "warn";
actions.append(choose);
}
actions.append(
button("Edit", () => startEdit(r)),
button("Delete", () => write(() => shared.remove("receipts", r.entity)) && draw()),
);
item.append(main, amount, actions);
list.append(item);
}
}
function button(label, onClick) {
const b = document.createElement("button");
b.type = "button";
b.className = "quiet";
b.textContent = label;
b.addEventListener("click", onClick);
return b;
}
function draw() {
$("me").value = myName();
if (!editing && !$("paid-by").value) $("paid-by").value = myName();
drawBalance();
drawList();
}
// ---- adding and editing -------------------------------------------------
function readForm() {
const amount = Number.parseFloat($("amount").value.replace(/[^0-9.]/g, ""));
if (!Number.isFinite(amount) || amount <= 0) {
say("Enter the amount as a number, like 12.50.");
return null;
}
return {
spent_on: $("spent-on").value,
store: $("store").value.trim(),
cents: Math.round(amount * 100),
paid_by: $("paid-by").value.trim(),
};
}
function resetForm() {
editing = null;
$("entry").reset();
$("spent-on").value = todayYmd();
$("paid-by").value = myName();
$("entry-title").textContent = "Add a receipt";
$("save-entry").textContent = "Add";
$("cancel-edit").hidden = true;
}
function startEdit(r) {
editing = r.entity;
$("spent-on").value = r.spent_on;
$("store").value = r.store;
$("amount").value = (r.cents / 100).toFixed(2);
$("paid-by").value = r.paid_by;
$("entry-title").textContent = "Edit receipt";
$("save-entry").textContent = "Save changes";
$("cancel-edit").hidden = false;
$("store").focus();
}
$("entry").addEventListener("submit", (event) => {
event.preventDefault();
const values = readForm();
if (!values) return;
// change() takes every column, not only the ones that changed.
const ok = write(() =>
editing ? shared.change("receipts", editing, values) : shared.insert("receipts", values),
);
if (ok) {
resetForm();
draw();
}
});
$("cancel-edit").addEventListener("click", () => resetForm());
$("me").addEventListener("change", () => {
// Local table: an ordinary UPDATE is right here.
db.exec({ sql: "UPDATE me SET name = ? WHERE id = 1", bind: [$("me").value.trim()] });
if (!editing) $("paid-by").value = myName();
});
$("share").addEventListener("click", () => window.dai.requestShare());
// ---- conflicts ----------------------------------------------------------
function openConflict(entity) {
// The competing versions are the current heads of this entity. A tombstone
// among them means the other copy deleted it while this one edited it.
const versions = rows(
`SELECT spent_on, store, cents, paid_by, _r_deleted AS deleted
FROM receipts_heads WHERE lower(hex(_r_entity)) = ?
ORDER BY _r_lc DESC`,
[entity],
);
const list = $("versions");
list.replaceChildren();
for (const v of versions) {
const item = document.createElement("li");
const text = document.createElement("span");
text.textContent = v.deleted
? "Deleted on one copy"
: `${v.store} · ${money(v.cents)} · ${dayWords(v.spent_on)} · ${v.paid_by}`;
const keep = button(v.deleted ? "Delete it" : "Keep this", () => {
// Writing the choice resolves the conflict: a change or a remove names
// every current version as its parent.
const ok = write(() =>
v.deleted
? shared.remove("receipts", entity)
: shared.change("receipts", entity, {
spent_on: v.spent_on,
store: v.store,
cents: v.cents,
paid_by: v.paid_by,
}),
);
if (ok) {
$("conflict").close();
draw();
}
});
item.append(text, keep);
list.append(item);
}
$("conflict").showModal();
}
$("conflict-close").addEventListener("click", () => $("conflict").close());
// ---- the other copy's rows ----------------------------------------------
// Nothing else says the other person's receipts arrived.
window.addEventListener("dai:merged", () => draw());
// Start-up (NO-INPUT-LOST-WHILE-OPENING): nothing can be typed into until this
// has finished, and if it fails the person is told, not left at "Opening…".
try {
db = await window.dai.openDatabase();
resetForm();
draw();
$("opening").hidden = true;
$("app").hidden = false;
$("app").inert = false;
} catch (error) {
$("opening").classList.add("failed");
$("opening").textContent =
`These receipts could not be opened: ${error?.message ?? error}. ` +
"Try opening the document again in the DAI opener, or ask whoever sent it for a new copy.";
}
--- file: app.css
:root {
color-scheme: light dark;
--bg: #f6f3ec;
--card: #ffffff;
--ink: #1f2a24;
--muted: #6a736d;
--line: #e3ddd0;
--accent: #2f6f4f;
--warn: #9a5b00;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #161a17; --card: #1f2520; --ink: #e8ece8; --muted: #9aa39c; --line: #2e3630; --accent: #7cc39c; --warn: #f0b35a; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); }
main {
max-width: 640px;
margin: 0 auto;
padding: calc(20px + var(--dai-safe-top, 0px)) calc(16px + var(--dai-safe-right, 0px))
calc(32px + var(--dai-safe-bottom, 0px)) calc(16px + var(--dai-safe-left, 0px));
}
/* The top right corner belongs to the host's menu button. */
header { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-right: 56px; }
h1 { margin: 0; font-size: 28px; flex: 1; }
h2 { font-size: 16px; margin: 0 0 12px; }
.me { display: flex; align-items: center; gap: 8px; color: var(--muted); }
.me input { width: 9em; }
input {
font: inherit; color: inherit; background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: 10px 12px; min-height: 44px; width: 100%;
}
button {
font: inherit; min-height: 44px; padding: 0 16px; border-radius: 10px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: var(--bg);
}
button.quiet { background: transparent; color: var(--accent); }
button.warn { background: transparent; color: var(--warn); border-color: var(--warn); }
.opening {
margin: 0; color: var(--muted);
padding: calc(28px + var(--dai-safe-top, 0px)) calc(16px + var(--dai-safe-right, 0px)) 0 calc(16px + var(--dai-safe-left, 0px));
text-align: center;
}
.opening.failed { color: var(--ink); text-align: left; max-width: 36em; margin: 0 auto; }
.notice { background: var(--card); border-left: 4px solid var(--warn); padding: 10px 12px; border-radius: 8px; }
.balance { margin: 20px 0; }
.balance p { margin: 4px 0; color: var(--muted); }
.balance .total { font-size: 22px; color: var(--ink); font-weight: 600; }
.entry, .list .receipt, dialog { background: var(--card); border: 1px solid var(--line); border-radius: 14px; }
.entry { padding: 16px; margin-bottom: 28px; }
.fields { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.fields label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); }
.actions { display: flex; gap: 8px; margin-top: 14px; }
.list-head { display: flex; justify-content: space-between; align-items: center; }
.list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
.receipt { display: grid; grid-template-columns: 1fr auto; gap: 6px 12px; padding: 12px 14px; }
.receipt.conflicted { border-color: var(--warn); }
.what { display: grid; gap: 2px; }
.what span { color: var(--muted); font-size: 14px; }
.amount { font-variant-numeric: tabular-nums; font-weight: 600; align-self: center; }
.row-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
.empty { color: var(--muted); }
dialog { color: var(--ink); max-width: min(520px, 92vw); padding: 20px; }
dialog::backdrop { background: rgb(0 0 0 / 0.4); }
.versions { list-style: none; padding: 0; display: grid; gap: 8px; }
.versions li { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
--- file: icon.svg
```
SESSION — two people play one game. Each game is a session: the creator plays X, whoever opens the invite takes the open seat. The board, the turn and the winner are derived from the marks; the application joins on a carrier open only, shows the contested and not-invited states, and closes a finished match.
```text
dai bundle v1
name: Tic-tac-toe
--- file: schema.sql
-- Tic-tac-toe by message · shape: session
--
-- The decision: two people play one game, each on their own phone, sending
-- the document back and forth. Both copies' marks must be kept, so the tables
-- are shared. And it is a closed group of two — a copy forwarded to a third
-- person must not let them play — so it is a session, not merely passable.
--
-- What that costs, and why:
-- * Every game is its own session. The creator plays X; whoever opens the
-- invite takes the open seat and plays O. Who is X and who is O is read
-- from the seats, never stored.
-- * No board, no turn, no winner. All of it is derived by replaying marks in
-- turn order. Two marks at one turn (the same side moved on two copies) is
-- shown to the players to settle, never decided silently.
-- * No UNIQUE(game_id, turn): it would refuse exactly the rows that make a
-- collision visible.
-- * Which game is showing is about this copy, so it is local.
-- dai:profile session max_parties=2 close=any
-- dai:replicated
CREATE TABLE IF NOT EXISTS games (
x_name TEXT NOT NULL,
o_name TEXT NOT NULL
);
-- dai:replicated
CREATE TABLE IF NOT EXISTS marks (
game_id TEXT NOT NULL, -- the games row's entity, as hex
turn INTEGER NOT NULL, -- 1-based; X plays odd turns, O even
cell INTEGER NOT NULL -- 0..8, left to right, top to bottom
);
-- Local: never merged.
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_game TEXT
);
--- file: index.html
Tic-tac-toe
Opening…
Tic-tac-toe
The player names were changed on both copies. Keep one:
--- file: app.js
// Tic-tac-toe — a session document. Each game is a session of two: the
// creator plays X, the invitee takes the open seat and plays O. The board, the
// turn and the winner are derived from marks every time; shared rows are
// written through window.dai.replicated and read from the _current views.
const $ = (id) => document.getElementById(id);
let db; // opened by the start-up at the end of this file
const shared = window.dai.replicated;
const rows = (sql, bind) => (bind === undefined ? db.selectObjects(sql) : db.selectObjects(sql, bind));
const one = (sql, bind) => rows(sql, bind)[0] ?? null;
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];
function say(text) {
$("notice").textContent = text;
$("notice").hidden = !text;
}
/** Runs writes and says what went wrong, rather than failing silently. */
function write(fn) {
try {
const result = fn();
say("");
return result ?? true;
} catch (error) {
const message = String(error?.message ?? error);
say(
message.startsWith("WRITE_SURFACE_UNAVAILABLE")
? "Open this document in the DAI opener to play."
: `That did not save: ${message}`,
);
return false;
}
}
// ---- reading ------------------------------------------------------------
/** This copy's replica id, or null before this copy has written anything. */
const myReplica = () => one("SELECT lower(hex(id)) AS id FROM _dai_replica")?.id ?? null;
function games() {
return rows(
`SELECT lower(hex(_r_entity)) AS id, lower(hex(_r_session)) AS session,
x_name, o_name, _r_conflicted AS conflicted
FROM games_current ORDER BY _r_lc`,
);
}
function activeGame() {
const list = games();
const id = one("SELECT active_game FROM settings WHERE id = 1")?.active_game;
return list.find((g) => g.id === id) ?? list[list.length - 1] ?? null;
}
/** Everything about the seats of a session, as this copy sees it. */
function seats(session) {
const mine = myReplica();
const creator = one(
"SELECT lower(hex(_r_replica)) AS r FROM _dai_seat_current WHERE lower(hex(_r_session)) = ? LIMIT 1",
[session],
)?.r;
const isMember = (replica) =>
!!replica &&
!!one("SELECT 1 AS x FROM _dai_member WHERE lower(hex(session)) = ? AND lower(hex(replica)) = ?", [session, replica]);
const bound = !!mine &&
!!one(
"SELECT 1 AS x FROM _dai_binding_current WHERE lower(hex(_r_session)) = ? AND lower(hex(_r_replica)) = ?",
[session, mine],
);
const contested = rows(
`SELECT 1 AS x FROM _dai_binding_current b
JOIN _dai_seat_current s ON s._r_session = b._r_session AND s.seat = b.seat
WHERE lower(hex(b._r_session)) = ?
GROUP BY b.seat HAVING count(DISTINCT b._r_replica) > 1`,
[session],
).length > 0;
const openSeat = one(
`SELECT lower(hex(s.seat)) AS seat FROM _dai_seat_current s
WHERE lower(hex(s._r_session)) = ?
AND s.seat NOT IN (SELECT b.seat FROM _dai_binding_current b WHERE b._r_session = s._r_session)
LIMIT 1`,
[session],
)?.seat ?? null;
const opponent = one(
`SELECT lower(hex(replica)) AS r FROM _dai_member
WHERE lower(hex(session)) = ? AND lower(hex(replica)) <> ? LIMIT 1`,
[session, creator ?? ""],
)?.r ?? null;
const closed = !!one("SELECT 1 AS x FROM _dai_close_current WHERE lower(hex(_r_session)) = ? LIMIT 1", [session]);
const amCreator = !!mine && mine === creator;
const member = isMember(mine);
return {
creator, opponent, amCreator, member, closed, contested, openSeat,
// Bound once, not admitted now: the seat was contested or replaced.
seatLost: bound && !member,
// Holds the rows but was never invited: forwarded, not joined.
notIn: !amCreator && !bound,
};
}
/** Replays the marks. Nothing here is stored. */
function state(game) {
const s = seats(game.session);
const marks = rows(
`SELECT lower(hex(_r_entity)) AS entity, lower(hex(_r_replica)) AS by, turn, cell
FROM marks_current WHERE game_id = ?
ORDER BY turn, _r_lc, lower(hex(_r_replica)), _r_seq`,
[game.id],
);
const board = Array(9).fill(null);
let turn = 1;
let collision = null;
for (;;) {
const side = turn % 2 === 1 ? "X" : "O";
const author = side === "X" ? s.creator : s.opponent;
const candidates = marks.filter((m) => m.turn === turn && m.by === author && board[m.cell] === null);
if (candidates.length === 0) break;
if (candidates.length > 1) {
collision = { turn, side, candidates };
break;
}
board[candidates[0].cell] = side;
turn += 1;
}
const line = LINES.find(([a, b, c]) => board[a] && board[a] === board[b] && board[a] === board[c]);
const winner = line ? board[line[0]] : null;
const full = board.every(Boolean);
const toMove = turn % 2 === 1 ? "X" : "O";
const mySide = s.amCreator ? "X" : s.member ? "O" : null;
const over = Boolean(winner) || full;
// X may move before O has joined: an X mark only needs the creator to be
// known. O's marks arrive in the same file as O's binding.
const canPlay = s.member && !s.closed && !over && !collision && mySide === toMove;
return { seats: s, board, turn, toMove, mySide, collision, winner, line, over, canPlay };
}
// ---- joining ------------------------------------------------------------
/**
* Take the open seat of the game showing, if this copy arrived with an invite.
* Called at start-up and when a file or link is opened — never on a background
* mailbox merge. The sent copy carries the sender's local settings (local
* tables travel; they are never merged), so the game showing is the one the
* invite was sent from.
*/
function joinIfInvited() {
// An invite carries only the game it was sent for, and none of the sender's
// local rows — so the game to join is one this copy can join: not its own,
// not one it is already in, with a seat open. That includes a copy whose seat
// was contested and replaced: opening the creator's fresh invite is how it
// gets back in. Prefer the game showing, if it is one.
const joinable = (g) => {
const s = seats(g.session);
return !s.member && !s.amCreator && !!s.openSeat;
};
const active = activeGame();
const target = active && joinable(active) ? active : [...games()].reverse().find(joinable);
if (!target) return;
const seat = seats(target.session).openSeat;
if (write(() => shared.session.join(target.session, seat))) {
db.exec({ sql: "UPDATE settings SET active_game = ? WHERE id = 1", bind: [target.id] });
}
}
// ---- drawing ------------------------------------------------------------
function nameOf(game, side) {
return side === "X" ? game.x_name : game.o_name;
}
function drawGameList() {
const list = $("game-list");
const current = activeGame();
list.replaceChildren();
for (const g of games()) {
const option = document.createElement("option");
option.value = g.id;
option.textContent = `${g.x_name} v ${g.o_name}`;
option.selected = g.id === current?.id;
list.append(option);
}
list.hidden = list.options.length < 2;
}
function drawSeat(game, st) {
const panel = $("seat");
const s = st.seats;
let text = "";
$("reseat").hidden = true;
if (s.contested && s.amCreator) {
text = "Two people opened this invite, so neither can play. Send a fresh invite to the one person you meant.";
$("reseat").hidden = false;
} else if (s.seatLost) {
text = `Your seat in this game was taken on another device or replaced. Nothing you did lost it — ${game.x_name} can send you a fresh invite.`;
} else if (s.notIn) {
text = `This game reached you, but you have not been invited into it. Open an invite from ${game.x_name} to play.`;
} else if (s.closed) {
text = "This match is closed.";
}
$("seat-text").textContent = text;
panel.hidden = !text;
}
function drawBoard(game, st) {
const board = $("board");
board.replaceChildren();
const canPlay = st.canPlay;
st.board.forEach((mark, cell) => {
const b = document.createElement("button");
b.type = "button";
b.className = "cell" + (st.line?.includes(cell) ? " win" : "");
b.textContent = mark ?? "";
b.setAttribute("aria-label", mark ? `Square ${cell + 1}, ${mark}` : `Square ${cell + 1}, empty`);
b.disabled = !canPlay || mark !== null;
b.addEventListener("click", () => {
write(() => shared.insert("marks", { game_id: game.id, turn: st.turn, cell }, game.session));
draw();
});
board.append(b);
});
}
function drawStatus(game, st) {
const s = st.seats;
let status;
if (st.winner) status = `${nameOf(game, st.winner)} wins.`;
else if (st.over) status = "A draw.";
else if (st.collision) status = "Two marks at one turn — settle it below.";
else if (st.canPlay) status = `Your move, ${nameOf(game, st.mySide)}.${s.opponent ? "" : " Then send the invite."}`;
else if (!s.member) status = "You are not playing in this game.";
else if (!s.opponent) status = "Waiting for your invite to be opened.";
else status = `${nameOf(game, st.toMove)}'s move. Send them this game.`;
$("status").textContent = status;
$("players").textContent = `${game.x_name} (X) v ${game.o_name} (O)`;
}
function drawCollision(game, st) {
const panel = $("collision");
panel.hidden = !st.collision;
if (!st.collision) return;
const c = st.collision;
$("collision-text").textContent =
`${nameOf(game, c.side)} marked two squares at the same turn, on two copies. Keep the one that should stand.`;
const choices = $("collision-choices");
choices.replaceChildren();
for (const m of c.candidates) {
const b = document.createElement("button");
b.type = "button";
b.textContent = `Keep square ${m.cell + 1}`;
b.disabled = !st.seats.member;
b.addEventListener("click", () => {
write(() => {
for (const other of c.candidates) if (other.entity !== m.entity) shared.remove("marks", other.entity);
});
draw();
});
choices.append(b);
}
}
function drawNamesConflict(game) {
const panel = $("names-conflict");
panel.hidden = !game.conflicted;
if (!game.conflicted) return;
const versions = rows(
"SELECT x_name, o_name FROM games_heads WHERE lower(hex(_r_entity)) = ? AND _r_deleted = 0",
[game.id],
);
const choices = $("name-versions");
choices.replaceChildren();
for (const v of versions) {
const b = document.createElement("button");
b.type = "button";
b.textContent = `${v.x_name} v ${v.o_name}`;
b.addEventListener("click", () => {
// A change names every current version as its parent, so it settles it.
write(() => shared.change("games", game.id, { x_name: v.x_name, o_name: v.o_name }));
draw();
});
choices.append(b);
}
}
function draw() {
drawGameList();
const game = activeGame();
$("play").hidden = !game;
if (!game) return;
const st = state(game);
drawStatus(game, st);
drawBoard(game, st);
drawCollision(game, st);
drawNamesConflict(game);
drawSeat(game, st);
$("invite").hidden = !(st.seats.amCreator && st.seats.openSeat && !st.seats.contested);
$("rename").hidden = !st.seats.member || st.seats.closed;
$("close-match").hidden = !(st.over && st.seats.member && !st.seats.closed);
}
// ---- acting -------------------------------------------------------------
$("new-game").addEventListener("submit", (event) => {
event.preventDefault();
const you = $("you").value.trim();
const them = $("them").value.trim();
if (!you || !them) return;
const made = write(() => {
db.exec("BEGIN");
try {
// A new game is a new session: this copy is seated, one seat is left open.
const { session } = shared.session.create();
const id = shared.insert("games", { x_name: you, o_name: them }, session);
db.exec({ sql: "UPDATE settings SET active_game = ? WHERE id = 1", bind: [id] });
db.exec("COMMIT");
return id;
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
});
if (made) $("new-game").reset();
draw();
});
$("game-list").addEventListener("change", () => {
db.exec({ sql: "UPDATE settings SET active_game = ? WHERE id = 1", bind: [$("game-list").value] });
draw();
});
// The host makes the invite: it mints the key and the link. Naming the game's
// session makes it an invite into this game only — the other games and this
// device's settings stay here.
$("invite").addEventListener("click", () => {
const game = activeGame();
if (game) window.dai.requestShare(game.session);
});
$("reseat").addEventListener("click", () => {
const game = activeGame();
if (game && write(() => shared.session.reseat(game.session))) window.dai.requestShare(game.session);
draw();
});
// An inline form, not prompt(): the application runs in a sandboxed frame,
// where the browser's own dialogs are not available.
$("rename").addEventListener("click", () => {
const game = activeGame();
if (!game) return;
$("rename-x").value = game.x_name;
$("rename-o").value = game.o_name;
$("rename-form").hidden = false;
$("rename-x").focus();
});
$("rename-cancel").addEventListener("click", () => {
$("rename-form").hidden = true;
});
$("rename-form").addEventListener("submit", (event) => {
event.preventDefault();
const game = activeGame();
const x = $("rename-x").value.trim();
const o = $("rename-o").value.trim();
if (!game || !x || !o) return;
// change() takes every column, not only the ones that changed.
if (write(() => shared.change("games", game.id, { x_name: x, o_name: o }))) $("rename-form").hidden = true;
draw();
});
$("close-match").addEventListener("click", () => {
const game = activeGame();
if (game) write(() => shared.session.close(game.session));
draw();
});
// The other player's marks arrive here, and nowhere else. Join only when a
// file or link was opened; a background mailbox merge never takes a seat.
window.addEventListener("dai:merged", (event) => {
if (event.detail?.via === "carrier") joinIfInvited();
draw();
});
// Start-up (NO-INPUT-LOST-WHILE-OPENING): nothing can be pressed until this has
// finished, and if it fails the person is told, not left at "Opening…".
try {
db = await window.dai.openDatabase();
joinIfInvited();
draw();
$("opening").hidden = true;
$("app").hidden = false;
$("app").inert = false;
} catch (error) {
$("opening").classList.add("failed");
$("opening").textContent =
`This game could not be opened: ${error?.message ?? error}. ` +
"Try opening the document again in the DAI opener, or ask whoever sent it for a new copy.";
}
--- file: app.css
:root {
color-scheme: light dark;
--bg: #f3f1fb;
--card: #ffffff;
--ink: #1d2340;
--muted: #666d8a;
--line: #dcd9ec;
--accent: #4a45c4;
--win: #e9e6ff;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #14172a; --card: #1d2340; --ink: #eceaff; --muted: #a2a6c4; --line: #2d3357; --accent: #a8a4ff; --win: #2f3470; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); }
main {
max-width: 480px;
margin: 0 auto;
padding: calc(20px + var(--dai-safe-top, 0px)) calc(16px + var(--dai-safe-right, 0px))
calc(32px + var(--dai-safe-bottom, 0px)) calc(16px + var(--dai-safe-left, 0px));
}
/* The top right corner belongs to the host's menu button. */
header { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding-right: 56px; }
h1 { margin: 0; font-size: 26px; flex: 1; }
h2 { margin: 0 0 12px; font-size: 16px; }
select, input {
font: inherit; color: inherit; background: var(--card); border: 1px solid var(--line);
border-radius: 10px; min-height: 44px; padding: 0 12px;
}
button {
font: inherit; min-height: 44px; padding: 0 16px; border-radius: 10px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: var(--bg);
}
button.quiet { background: transparent; color: var(--accent); }
button:disabled { opacity: 0.5; cursor: default; }
.opening {
margin: 0; color: var(--muted);
padding: calc(28px + var(--dai-safe-top, 0px)) calc(16px + var(--dai-safe-right, 0px)) 0 calc(16px + var(--dai-safe-left, 0px));
text-align: center;
}
.opening.failed { color: var(--ink); text-align: left; max-width: 36em; margin: 0 auto; }
.notice { background: var(--card); border-left: 4px solid var(--accent); padding: 10px 12px; border-radius: 8px; }
.players { color: var(--muted); margin: 16px 0 4px; }
.status { font-size: 20px; font-weight: 600; margin: 0 0 16px; }
.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; aspect-ratio: 1; }
.cell {
font-size: clamp(32px, 12vw, 64px); font-weight: 700; aspect-ratio: 1; min-height: 64px;
background: var(--card); color: var(--ink); border: 1px solid var(--line);
}
.cell:disabled { opacity: 1; }
.cell.win { background: var(--win); }
.panel { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-top: 16px; }
.panel label { display: grid; gap: 6px; margin-bottom: 12px; color: var(--muted); }
.choices, .actions { display: flex; flex-wrap: wrap; gap: 8px; }
.actions { margin-top: 16px; }
--- file: icon.svg
```
BROADCAST — no example. Build it exactly as solo (SHAPE-BROADCAST-CONVENTION).
THE MISTAKE THESE EXAMPLES AVOID. The first chess application written from an earlier version of these instructions marked its tables shared and then stored the board, the turn, the result and an updated_at column in the games table; changed that row in place with UPDATE on every move; kept the selected square and the theme in the shared table; put UNIQUE(game_id, ply) on moves; read the moves table itself; and never listened for dai:merged. After the first exchange each copy showed a board computed from the moves it had, and the other player's move never appeared. The correct version stores only games, moves and game events, derives the board by replaying the moves, keeps the per-copy state local, lets two moves at one ply exist so the players can see and settle it, reads the _current views, redraws on dai:merged, and declares a session.
BEFORE YOU ANSWER, CHECK
Every constraint for the shape you chose, by ID:
- solo: SHAPE-FIRST, NO-NETWORK, STORE-IN-SQLITE, MODULE-FOR-AWAIT, NO-INLINE-HANDLERS, NO-NEW-WINDOWS, ONE-DOCUMENT, SHARE-THROUGH-HOST, SCHEMA-FILE, SEED-IDEMPOTENT, WRITE-AS-IT-HAPPENS, NO-INPUT-LOST-WHILE-OPENING, NO-SAVE-BUTTON, MIGRATE-CHANGED-TABLES, TIMES-IN-UTC, KIT-FIRST, ICON-SVG, DESCRIBE-ON-CARD, EDGE-TO-EDGE, TOP-RIGHT-CLEAR, ONE-LAYOUT, LOOK-FINISHED, HANDOVER-BUNDLE
- passable: SHAPE-FIRST, NO-NETWORK, STORE-IN-SQLITE, MODULE-FOR-AWAIT, NO-INLINE-HANDLERS, NO-NEW-WINDOWS, ONE-DOCUMENT, SHARE-THROUGH-HOST, SCHEMA-FILE, SEED-IDEMPOTENT, WRITE-AS-IT-HAPPENS, NO-INPUT-LOST-WHILE-OPENING, NO-SAVE-BUTTON, MIGRATE-CHANGED-TABLES, TIMES-IN-UTC, SHARED-MARKER, SHARED-DECIDE-UP-FRONT, SHARED-NO-KEY, SHARED-NO-UNIQUE-CHECK, SHARED-NO-R-COLUMNS, SHARED-WRITE-SURFACE, SHARED-READ-CURRENT, SHARED-SURFACE-CONFLICTS, SHARED-REDRAW-ON-MERGE, SHARED-NO-DERIVED-STATE, SHARED-LOCAL-STAYS-LOCAL, SHARED-ENTITY-IDENTITY, SHARED-SEED-THROUGH-SURFACE, SHARED-NEEDS-HOST, KIT-FIRST, SHARED-KIT-READS, ICON-SVG, DESCRIBE-ON-CARD, EDGE-TO-EDGE, TOP-RIGHT-CLEAR, ONE-LAYOUT, LOOK-FINISHED, HANDOVER-BUNDLE
- session: SHAPE-FIRST, NO-NETWORK, STORE-IN-SQLITE, MODULE-FOR-AWAIT, NO-INLINE-HANDLERS, NO-NEW-WINDOWS, ONE-DOCUMENT, SHARE-THROUGH-HOST, SCHEMA-FILE, SEED-IDEMPOTENT, WRITE-AS-IT-HAPPENS, NO-INPUT-LOST-WHILE-OPENING, NO-SAVE-BUTTON, MIGRATE-CHANGED-TABLES, TIMES-IN-UTC, SHARED-MARKER, SHARED-DECIDE-UP-FRONT, SHARED-NO-KEY, SHARED-NO-UNIQUE-CHECK, SHARED-NO-R-COLUMNS, SHARED-WRITE-SURFACE, SHARED-READ-CURRENT, SHARED-SURFACE-CONFLICTS, SHARED-REDRAW-ON-MERGE, SHARED-NO-DERIVED-STATE, SHARED-LOCAL-STAYS-LOCAL, SHARED-ENTITY-IDENTITY, SHARED-SEED-THROUGH-SURFACE, SHARED-NEEDS-HOST, SESSION-PROFILE, SESSION-CREATE, SESSION-ROW-CARRIES-SESSION, SESSION-JOIN-ON-OPEN, SESSION-MEMBERSHIP, SESSION-CONTESTED-SEAT, SESSION-CLOSE, SESSION-INVITE, KIT-FIRST, SHARED-KIT-READS, ICON-SVG, DESCRIBE-ON-CARD, EDGE-TO-EDGE, TOP-RIGHT-CLEAR, ONE-LAYOUT, LOOK-FINISHED, HANDOVER-BUNDLE
- broadcast: SHAPE-FIRST, SHAPE-BROADCAST-CONVENTION, NO-NETWORK, STORE-IN-SQLITE, MODULE-FOR-AWAIT, NO-INLINE-HANDLERS, NO-NEW-WINDOWS, ONE-DOCUMENT, SHARE-THROUGH-HOST, SCHEMA-FILE, SEED-IDEMPOTENT, WRITE-AS-IT-HAPPENS, NO-INPUT-LOST-WHILE-OPENING, NO-SAVE-BUTTON, MIGRATE-CHANGED-TABLES, TIMES-IN-UTC, KIT-FIRST, ICON-SVG, DESCRIBE-ON-CARD, EDGE-TO-EDGE, TOP-RIGHT-CLEAR, ONE-LAYOUT, LOOK-FINISHED, HANDOVER-BUNDLE
And for every shape: the files are handed over as a tool call or as ONE fenced bundle; icon.svg exists; index.html has a description line and three dai:does lines; the background reaches every edge and content pads with var(--dai-safe-*, 0px).