Make your own
You do not need to know how to write code. You need an assistant to write it, and somewhere to turn what it writes into a file. That is this page.
Nothing you paste here is uploaded. The whole thing is compiled in your browser — there is no server behind this page: we never see your app, and neither does anyone else.
1. Ask an assistant
Copy this, paste it into ChatGPT, Claude or Gemini, and finish the last line with what you want — a workout log, a recipe box, a tracker for your sourdough starter. Then ask it for the files, or for a zip.
See what it says
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 <script> that uses top-level await must be type="module".
Why: In a classic script top-level await is a syntax error, and the application opens blank.
[NO-INLINE-HANDLERS] No onclick attributes
Applies to: every shape. Enforcement: checked by the lint (inline-event-handler).
Attach every event handler in script with addEventListener. Never write an event attribute such as onclick or onsubmit in HTML.
Why: A container allows no inline script, so the attribute never runs and the control does nothing, with no error to explain why.
[NO-NEW-WINDOWS] No new windows, no redirects
Applies to: every shape. Enforcement: checked by the lint (new-window, meta-refresh).
Never open a window or tab (window.open, target="_blank") and never redirect with a meta refresh.
Why: A container cannot open windows or navigate anywhere, so the link does nothing or the application blanks.
[ONE-DOCUMENT] One document, many rows
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Model every separate instance — a game, a match, a list, a save slot — as a row in the application's own schema, with a local setting recording which one is showing. Never offer a "new file": nothing in a running application can create a second document.
Why: A document is one sealed file with one database, and no call creates, forks or duplicates one. A "New Game" button that promises a new file promises something that cannot happen.
[SHARE-THROUGH-HOST] Sharing is the host's
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
To let a person share, put a Share button in the application and call `window.dai.requestShare()`. Never build a share flow of your own with the browser's share API. For a shared document the host sends a link, which is also how an invite reaches the other party.
Why: The host builds the link, shows the card, and asks whether to include the person's data; a substitute flow would carry none of that, and for a shared document a file sent any other way carries no key and can never sync.
DATA
[SCHEMA-FILE] Every table is in schema.sql
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Declare every table in one file named schema.sql, each with CREATE TABLE IF NOT EXISTS. Create no table anywhere else — not in index.html, not in JavaScript.
Why: schema.sql runs first on every open and its shape is sealed with the file; that record is what protects a person's data when a later version changes the application.
[SEED-IDEMPOTENT] Seed rows are idempotent, and local
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Put a few example rows in a <script type="application/sql"> block in index.html so the application is not an empty shell on first open, written so a second open adds nothing (INSERT … WHERE NOT EXISTS, or INSERT OR IGNORE with a fixed id). Never seed from schema.sql. Seed only local tables this way; see SHARED-SEED-THROUGH-SURFACE for shared ones.
Why: The block runs on every open, so a seed that is not idempotent duplicates itself each time the document is opened.
[WRITE-AS-IT-HAPPENS] The database is the state
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Write every action a person takes — a tick, a new row, an edit — to the database at the moment they take it. Never hold the application's state in a JavaScript variable to write later. On load, read the database and draw from it; after a write, read again and redraw.
Why: Whatever is only in a variable is lost when the document closes, and it is not in the copy that gets sent.
[NO-INPUT-LOST-WHILE-OPENING] Nothing a person does while the app is opening is lost
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Until the application's script has finished starting — the database open, the handlers attached, the first draw done — show nothing a person can type into or submit. Put the interactive part of the page in an element marked both hidden and inert, `<main id="app" hidden inert>`, beside a short line such as "Opening…". `inert` is what keeps it out of reach: `hidden` alone is undone by any style rule that sets `display` on that element, while an inert element takes no focus, typing or clicks whatever the CSS says. Run the whole start-up — `openDatabase()`, the first draw — inside try/catch. On success, hide the line and remove both attributes. On failure, replace the line with what went wrong and what the person can do next: never leave "Opening…" showing over a start-up that has already ended. Do not wait for an event to learn that start-up failed — the frame's `dai:error` message is posted outward to the host, is never delivered to the application, and nothing acts on it today; the application's own catch is the only place that knows. A page built only from the kit's elements is already safe: they are not `<form>` elements, so a button inside them submits nothing before the kit has started. A `<form>` of your own is not.
Why: The script's start-up waits on `await window.dai.openDatabase()`, which in a shared document waits for the host's write rules. A form on screen before then takes a person's typing while the application cannot yet handle it. Pressing Enter or its button then either makes the browser submit the form itself and replace the page, or does nothing at all — and the script's own start-up resets the form a moment later. Which of the two happens varies between browsers and between one opening and the next; either way what they typed is gone, with nothing to say why. Both blind runs copied examples that showed their forms early. The same failure arriving mid-edit, when another copy's rows land, is SHARED-REDRAW-ON-MERGE.
[NO-SAVE-BUTTON] Saving is automatic
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Build no Save button, no "saved" indicator and no dirty flag. Under a host every write is saved as it happens (`window.dai.autosaves` is true). A page that uses the kit includes <dai-save> once, at the bottom: it appears only when the file was opened straight in a browser with no host, where saving takes a tap, and hides itself everywhere else. A page without the kit that must save with no host calls `window.dai.saveDatabase(db)` from a control shown only when `window.dai.autosaves` is false. A shared document needs no such control: with no host it cannot write its shared tables at all (SHARED-NEEDS-HOST).
Why: A save control under a host is a control that does nothing, and a person who presses it learns to distrust the rest.
[MIGRATE-CHANGED-TABLES] A changed table needs a migration
Applies to: every shape. Enforcement: refused at build.
When a later version changes an existing table, add a migration — one file in migrations/, named with the next number (migrations/002-add-priority.sql), holding the ALTER statements that move the old shape to the new — and update schema.sql to match. Adding a table needs no migration. Never drop a table to get past the check.
Why: A version whose schema moved without a migration is refused at build, because the old file holds somebody's data and nothing else says how to carry it forward.
[TIMES-IN-UTC] Store UTC, show words
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Store times as SQLite text in UTC (datetime('now')) and show them the way a person reads them — "today", "2 hours ago" — never as 2026-09-04 15:01:27. A time on a shared table is a fact somebody entered (a receipt's date), never a record of when a row was written: see SHARED-NO-DERIVED-STATE.
Why: Raw timestamps read as a machine talking. And a write time on a shared row is a second opinion about order that the replication already records.
SHARED TABLES
[SHARED-MARKER] Mark a shared table
Applies to: passable, session. Enforcement: refused at build.
Put the line -- dai:replicated directly above each table that more than one copy writes, with nothing but whitespace between the comment and CREATE TABLE. Only tables every party must agree on get it; everything about one copy stays local (SHARED-LOCAL-STAYS-LOCAL).
Why: The compiler rewrites a marked table into an append-only one with the columns, key, triggers and views replication needs. A marker further up is an ordinary comment and declares nothing.
[SHARED-DECIDE-UP-FRONT] Decide which tables are shared before the first release
Applies to: passable, session. Enforcement: not checked by anything — follow it anyway.
Decide whether each table is shared before the application is first released, and do not plan to convert a local table into a replicated one later: that path is not supported or tested today.
Why: A replicated table carries the rewrite in every copy already saved, and whether a migration can turn an existing local table into one has not been established. Deciding the shape first (SHAPE-FIRST) is what makes this cheap.
[SHARED-NO-KEY] No PRIMARY KEY, no AUTOINCREMENT
Applies to: passable, session. Enforcement: refused at build.
A replicated table declares no PRIMARY KEY and no AUTOINCREMENT. Its identity is the entity the write surface returns (SHARED-ENTITY-IDENTITY).
Why: The key belongs to replication. Two copies both advancing one counter allocate the same ids for different rows.
[SHARED-NO-UNIQUE-CHECK] No UNIQUE, no CHECK
Applies to: passable, session. Enforcement: checked by the lint (shared-table-constraint).
A replicated table declares no UNIQUE and no CHECK constraint, on a column or on the table.
Why: A UNIQUE(game_id, ply) refuses exactly the rows a merge exists to surface: two people acting at the same point is a conflict to show a person, not an error to raise at them. A CHECK that differs between two versions of the application rejects the other copy's honest rows, and they arrive as rejected rows rather than a refused merge, so nobody can see what happened.
[SHARED-NO-R-COLUMNS] No column names beginning _r_
Applies to: passable, session. Enforcement: refused at build.
Name no column of your own with the prefix _r_.
Why: That prefix is replication's; the rewrite adds _r_replica, _r_seq, _r_lc, _r_entity, _r_parents, _r_deleted, _r_superseded, _r_sig and, in a session document, _r_session.
[SHARED-WRITE-SURFACE] Write shared rows only through window.dai.replicated
Applies to: passable, session. Enforcement: refused at run time; checked by the lint (shared-raw-write).
Write to a replicated table only with `window.dai.replicated.insert(table, values)`, `.change(table, entity, values)` and `.remove(table, entity)`, after `await window.dai.openDatabase()`. `values` is an object of your own columns. `change` takes every one of your columns, not only the ones that changed. Never run INSERT, UPDATE or DELETE against a replicated table — not in JavaScript, not in a kit control, not in a seed block.
Why: Rows are appended and never changed in place: an UPDATE or DELETE is refused with REPLICATED_TABLE_IMMUTABLE, and a raw INSERT fails because it lacks the replication columns only the write surface fills in.
[SHARED-READ-CURRENT] Read shared rows from the _current view
Applies to: passable, session. Enforcement: checked by the lint (shared-base-read).
Read a replicated table t only through the view t_current, which holds one row per live entity. Never SELECT from t itself for display or logic. Use t_conflicts or t_heads only to show or resolve a conflict (SHARED-SURFACE-CONFLICTS).
Why: The base table holds every version of every row: superseded edits, tombstones of deleted rows, and — in a session — rows from non-members and rows written after the close. Reading it shows all of them at once.
[SHARED-SURFACE-CONFLICTS] Show conflicts; never pick silently
Applies to: passable, session. Enforcement: checked by the lint (shared-conflicts-unshown); not checked by anything — follow it anyway.
There are two kinds of conflict, and an application with shared tables must show both to the person and let them resolve it. (1) The same row edited on two copies: t_current still shows one version, with `_r_conflicted` = 1; the competing versions are the rows of t_heads for that entity (t_conflicts lists the entities). Show that it happened and offer the versions; the person resolves it by choosing, which the application writes as `change(table, entity, chosenValues)` — a change names every current head, so it settles the conflict. (2) Two new rows that claim one slot — two moves at the same turn, two people taking the same shift. Replication cannot see this (they are different rows); the application derives it from the rows (two rows with the same ply) and shows it, and the person keeps one while the other is removed.
Why: A merge that picked one version or one row and hid the other would read to a person as lost data. The runtime surfaces conflicts precisely so that the person, not the order the files arrived in, decides.
[SHARED-REDRAW-ON-MERGE] Redraw when the other copy's rows arrive
Applies to: passable, session. Enforcement: checked by the lint (shared-no-merge-listener).
Listen for the `dai:merged` event on window and redraw everything drawn from shared tables when it fires: `window.addEventListener("dai:merged", (event) => { redraw(); })`. `event.detail` carries `applied`, `duplicate`, `rejected`, `newReplicas`, `conflicts` and `via` — "carrier" when a file or link was opened, "mailbox" when rows arrived in the background. If the page uses the kit's reading elements, call `window.daiKit.refresh()` in the listener. A redraw must never discard what the person is in the middle of — text typed into a field, an editor that is open, a selection: rows arrive whenever the other copy's changes do, including mid-sentence. Keep work in progress outside what the redraw rebuilds — in a form written once in the HTML rather than recreated on every draw, or in a local drafts table the redraw reads back — or leave the element being edited untouched until it is saved or cancelled. The same failure arriving at start-up rather than mid-edit is NO-INPUT-LOST-WHILE-OPENING.
Why: Nothing else tells the application that another copy's rows landed. Without it the application draws once and redraws only after its own writes, so a two-person document looks broken in exactly the case it exists for. And a redraw that rebuilds an open editor from the stored wording throws away what was being typed, silently — found by running a blind candidate over the mailbox, where a background merge landed while a term was being edited.
[SHARED-NO-DERIVED-STATE] Store facts, derive everything else
Applies to: passable, session. Enforcement: not checked by anything — follow it anyway.
Store in a shared table only the facts people enter or acts they take — a receipt, a move, a mark. Never store anything computable from them: no board, no score, no turn, no total, no balance, no "last updated", no status that follows from other rows. Compute it from the rows each time it is drawn, preferably in SQL.
Why: A stored total is a second opinion about what the rows say, and after a merge it is wrong: each copy computed it from the rows it had, and neither computed it from the union.
[SHARED-LOCAL-STAYS-LOCAL] What belongs to one copy stays local
Applies to: passable, session. Enforcement: not checked by anything — follow it anyway.
Keep in ordinary local tables everything about this copy rather than the document: settings, drafts, which item the screen is showing, what this person has hidden, the name this person goes by. Local tables are never merged, so they may use PRIMARY KEY, UNIQUE and CHECK freely. They travel only in a whole-document copy — a file, or the host menu's share — where a person opening it for the first time starts from the sender's local rows. An invite into one session carries none of them, and a copy that already exists keeps its own local rows when another copy's shared rows are merged into it.
Why: A setting in a shared table changes the other person's screen, and a draft in one is sent before it is finished.
[SHARED-ENTITY-IDENTITY] A shared row's identity is its entity
Applies to: passable, session. Enforcement: not checked by anything — follow it anyway.
Refer to a shared row by the entity the write surface returned: 32 lowercase hex characters. Read it back as `lower(hex(_r_entity))`. To point one shared row at another (a move at its game), store that hex string in an ordinary TEXT column and compare it with `lower(hex(_r_entity))`. A particular version of a row is its key, `(_r_replica, _r_seq)` — the same on every copy — so an application that needs to name "this exact wording" (what a person accepted, say) can use it.
Why: The entity is the same on every copy, where an id of your own would be allocated separately on each.
[SHARED-SEED-THROUGH-SURFACE] Shared rows are never seeded with SQL
Applies to: passable, session. Enforcement: refused at run time; checked by the lint (shared-raw-write).
Seed no replicated table from a <script type="application/sql"> block. Prefer to seed nothing shared: an empty shared table with a good empty state is correct. If a shared example row is essential, insert it with `window.dai.replicated.insert` once, guarded by a flag in a local table, so a second open and a second copy do not add it again.
Why: A raw INSERT into a replicated table fails (it lacks the replication columns), and a seed that every copy writes on its own first open puts a duplicate in every merge.
[SHARED-NEEDS-HOST] A shared document writes only under a host
Applies to: passable, session. Enforcement: refused at run time.
Expect shared tables to be writable only when the document is opened by a host — the DAI opener or the desktop app — which delivers the write rules. Opened straight in a browser as a plain file, `openDatabase()` resolves after the rules wait (about 10 seconds) and every shared write is refused with WRITE_SURFACE_UNAVAILABLE. Catch that error around writes and tell the person to open the document in the opener; local tables still work.
Why: The rules that stamp and merge a replicated row come from the host. A shared write without them would be a row no other copy could merge.
SESSIONS
[SESSION-PROFILE] Declare the session profile
Applies to: session. Enforcement: refused at build.
Declare a session document with one line comment in schema.sql: -- dai:profile session max_parties=N close=any|creator. N is the most people the document allows, at least 1 — but today a session seats two whatever N says: `session.create()` mints the creator's seat and one open seat, and no call adds another (backlog D6). Declare max_parties=2, and do not build an application that needs a third member. close=any lets any member close a session; close=creator lets only the person who created it; it defaults to any. The document must also have at least one table marked -- dai:replicated. Every replicated table then carries the session of each row.
Why: The profile is signed into the document, so the size of the group is the creator's stated limit rather than something the application decides. A malformed profile, or one with no replicated table, is refused at build.
[SESSION-CREATE] A new game is a new session
Applies to: session. Enforcement: not checked by anything — follow it anyway.
Start each game, match or agreement with `const { session, seat } = window.dai.replicated.session.create()`. It seats the creator and leaves one open seat for the invitee; `session` is the id to keep (hex), `seat` is the open seat. Then insert the thing itself — the games row — with that session (SESSION-ROW-CARRIES-SESSION). Do both in one transaction if you write local rows beside them: `session.create()` and `insert` work inside a `BEGIN` … `COMMIT` you open. The creator is a member from the moment the session exists, so the creator's rows are admitted before anyone has joined — the first move can be made before the invite is sent.
Why: A session is the unit of membership. Rows written outside one belong to nobody, and a second game in the same session would share the first game's roster.
[SESSION-ROW-CARRIES-SESSION] Every insert names its session
Applies to: session. Enforcement: refused at run time.
In a session document, pass the session id as the third argument of every insert: `window.dai.replicated.insert("moves", values, session)`. `change` and `remove` take no session — they inherit the entity's.
Why: Every replicated row in a session document belongs to a session. Run against the write rules: an insert without one throws "A row for … carries no session, but <table> declares the session profile", and nothing is written.
[SESSION-JOIN-ON-OPEN] Take the open seat when an invite is opened
Applies to: session. Enforcement: not checked by anything — follow it anyway.
When this copy opens an invite, bind its open seat with `window.dai.replicated.session.join(session, seat)`: once at start-up, and again in the `dai:merged` listener only when `event.detail.via === "carrier"` — never for "mailbox". Join only if this copy is not already a member and an open seat exists: the open seat is a `_dai_seat_current` row for the session whose seat no `_dai_binding_current` row binds. Join the session the invite was sent for. An invite carries only that session and none of the sender's local rows (SESSION-INVITE), so it is a session with an open seat that this copy did not create and is not a member of — in a fresh copy made from an invite there is exactly one. That includes a copy whose seat was contested or replaced: opening the creator's fresh invite is how it gets back in, and excluding copies that were ever seated would lock it out for good. Prefer the item that is showing when it is joinable (a copy that arrived as a whole document carries the sender's local rows, including which item was showing), otherwise take the newest joinable one, and make it the item showing.
Why: Membership comes from opening an invite, not from rows arriving. A copy that joined on every background merge would re-take a seat it had lost, and a copy that joined twice would contest its own seat.
[SESSION-MEMBERSHIP] Read membership, and show the three ways to be outside
Applies to: session. Enforcement: not checked by anything — follow it anyway.
This copy's replica id is `SELECT lower(hex(id)) AS id FROM _dai_replica`. It is a member of a session when `_dai_member` has a row for (session, replica). Enable writing only for members, and show a copy that is not one which of three states it is in: it holds the rows but never joined (it was forwarded the document, not invited); it joined but its seat was contested or replaced (SESSION-CONTESTED-SEAT); or the session is closed (SESSION-CLOSE). The _current views of a session document show only admitted rows — rows by members, written before any close.
Why: A non-member's rows are kept but never admitted, so an application that let a non-member play would show them their own moves and nobody else ever would. Saying which state a copy is in is the difference between a message and a hang.
[SESSION-CONTESTED-SEAT] A contested seat is a state to show
Applies to: session. Enforcement: refused at run time; not checked by anything — follow it anyway.
A seat bound by two or more different replicas is contested — two people opened the same invite — and admits neither. Detect it as a `_dai_binding_current` seat with `count(DISTINCT _r_replica) > 1` for the session. Show the creator that the invite went to more than one device and offer a fresh invite: `window.dai.replicated.session.reseat(session)`, then share again. Show a copy whose own seat was lost that nothing it did lost its place, and that the creator can send a new invite. `reseat` refuses with NOT_SEAT_CREATOR for anyone but the creator and with CANNOT_RESEAT when no seat is contested.
Why: It is resolved without a clock deciding who opened the invite first, so neither copy can be admitted until the creator repairs it; an application that treated it as an error would leave both people stuck.
[SESSION-CLOSE] Closing is separate from finishing
Applies to: session. Enforcement: refused at run time; not checked by anything — follow it anyway.
Ending the activity is an ordinary row: a resignation, a final mark, a signature. Closing the session is a separate, heavier act — `window.dai.replicated.session.close(session)` — after which rows written later than what the closer had seen are not admitted. Offer it only on a finished session, never as the way to end a live one. Closing as part of an act whose point is finality — sealing an agreement once both have accepted it — is exactly what close is for: write the act as a row, then close. Read whether a session is closed from `_dai_close_current` (any row for the session). Under close=creator a non-creator's close is refused with CLOSE_NOT_PERMITTED; hide or disable the control for them.
Why: A close is final for the group, and it is decided by what the closer had seen rather than by a clock. Folding it into "resign" would end a session the other person had not finished with.
[SESSION-INVITE] An invite is a shared link
Applies to: session. Enforcement: not checked by anything — follow it anyway.
Invite the other party by asking the host to share, naming the session: a button that calls `window.dai.requestShare(session)`. There is no invite call of your own. The copy that travels holds only that session's rows — none of the document's other sessions, and none of this copy's local tables — so the recipient gets this one game and nothing else of the sender's. Without a session, `requestShare()` offers the whole document, every session in it, as the host's own menu does; use it for that, never for an invite. After a copy has been shared by link, the host moves new rows between the copies on its own, and they arrive as `dai:merged` with `via` "mailbox"; a copy handed over as a file carries its rows when it is opened. The application never sends rows itself; a "send" button that calls `requestShare()` again is only needed where copies travel as files.
Why: The host mints the key that lets the two copies exchange rows and makes the link; the application only asks, and only the application knows which game it is inviting to. Filtering to that game is what keeps a person's other games — and whatever they keep only on their own device — out of every invite they send.
THE KIT
[KIT-FIRST] Prefer the kit for local tables
Applies to: every shape. Enforcement: not checked by anything — follow it anyway.
Use dai-kit for local tables: <dai-rows>, <dai-value>, <dai-form>, <dai-attach> and <dai-save>, with <script type="module" src="./dai-kit.js"></script> 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, <dai-form run=…>, <dai-attach 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, <dai-rows> and <dai-value>, 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 <head> of index.html: <meta name="description" content="…"> — what it is for, under 60 characters, the way a store page puts a line under an app's name; and exactly three <meta name="dai:does" content="…"> lines, each under 90 characters, starting with a verb, saying what somebody would tell a friend it does. Three, or none. Beside them, <meta name="theme-color" content="…"> 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: <path>". 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 <dai-rows> or <dai-value>.
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 <dai-rows> or <dai-value>.
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 <table> 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'))
);
<script type="application/sql">
INSERT INTO notes (body) SELECT 'Try editing this' WHERE NOT EXISTS (SELECT 1 FROM notes);
</script>
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:
<dai-value query="SELECT count(*) AS n FROM tasks WHERE done = 0"></dai-value> left
<dai-form run="INSERT INTO tasks (title) VALUES (:title)">
<input name="title" required>
<button type="button">Add</button>
</dai-form>
<dai-rows query="SELECT id, title, done FROM tasks ORDER BY id" empty="Nothing to do">
<template>
<li>
<input type="checkbox" data-run="UPDATE tasks SET done = 1 - done WHERE id = :id">
<span data-text="title"></span>
</li>
</template>
</dai-rows>
<dai-save>Save</dai-save> <!-- hides itself under a host -->
<script type="module" src="./dai-kit.js"></script>
- 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: <dai-attach run="UPDATE entries SET photo = :file WHERE id = :id" data-id="1">Add a photo</dai-attach>, and <img data-blob="photo" alt=""> 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
<!doctype html>
<meta name="description" content="Books to read, and the ones you did">
<meta name="dai:does" content="Keep a list of what you want to read next">
<meta name="dai:does" content="Mark a book finished and see what you got through">
<meta name="dai:does" content="Search by author or title as the list grows">
…
--- file: schema.sql
CREATE TABLE IF NOT EXISTS books (…);
--- file: app.js
const db = await window.dai.openDatabase();
--- file: icon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">…</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
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Everything for the beach, ticked off as it goes in the bag" />
<meta name="dai:does" content="Keeps everything for the trip in one list you can add to" />
<meta name="theme-color" content="#f2f8fb" />
<meta name="dai:does" content="Tick each thing off as it goes in the bag" />
<meta name="dai:does" content="Shows what is still missing before you leave" />
<title>Beach trip</title>
<link rel="stylesheet" href="./app.css" />
</head>
<body>
<!--
A packing list for one trip. Made for the website's front page: somebody
should look at this and think of the trip they have coming up.
No JavaScript of its own; the kit does the wiring, and the schema and
starting data are SQL in the document.
-->
<script type="application/sql">
-- seed rows; the tables are declared in schema.sql
INSERT OR IGNORE INTO items (id, kind, what, packed) VALUES
(1, 'Beach', 'Sunscreen', 1),
(2, 'Beach', 'Towels ×4', 1),
(3, 'Beach', 'Bucket and spade', 0),
(4, 'Beach', 'Umbrella', 0),
(5, 'Clothes', 'Swimsuits', 1),
(6, 'Clothes', 'Hats', 0),
(7, 'Clothes', 'Sandals', 0),
(8, 'Kids', 'Floaties', 1),
(9, 'Kids', 'Snacks for the car', 0),
(10, 'Kids', 'Bear', 0);
INSERT OR IGNORE INTO trip (id, dates) VALUES (1, 'Sat 14 - Sun 22');
</script>
<main>
<header>
<!--
The dates, which are the one thing on this page that is about a
particular trip rather than about packing. Typed over, not decoration:
a list somebody cannot date is a list about somebody else's holiday.
-->
<dai-rows query="SELECT id, dates FROM trip WHERE id = 1">
<template>
<input
class="eyebrow"
aria-label="When the trip is"
data-text="dates"
data-run="UPDATE trip SET dates = :typed WHERE id = 1"
/>
</template>
</dai-rows>
<h1>Beach trip</h1>
<p class="progress">
<dai-value query="SELECT count(*) FROM items WHERE packed = 1"></dai-value>
of
<dai-value query="SELECT count(*) FROM items"></dai-value>
packed
</p>
</header>
<section>
<h2>Beach</h2>
<dai-rows query="SELECT id, what, packed FROM items WHERE kind = 'Beach' ORDER BY packed, id">
<template>
<label class="item">
<input type="checkbox" data-run="UPDATE items SET packed = 1 - packed WHERE id = :id" />
<span class="box" aria-hidden="true"></span>
<span class="what" data-text="what"></span>
<span class="in" data-when="packed">packed</span>
</label>
</template>
</dai-rows>
</section>
<section>
<h2>Clothes</h2>
<dai-rows query="SELECT id, what, packed FROM items WHERE kind = 'Clothes' ORDER BY packed, id">
<template>
<label class="item">
<input type="checkbox" data-run="UPDATE items SET packed = 1 - packed WHERE id = :id" />
<span class="box" aria-hidden="true"></span>
<span class="what" data-text="what"></span>
<span class="in" data-when="packed">packed</span>
</label>
</template>
</dai-rows>
</section>
<section>
<h2>Kids</h2>
<dai-rows query="SELECT id, what, packed FROM items WHERE kind = 'Kids' ORDER BY packed, id">
<template>
<label class="item">
<input type="checkbox" data-run="UPDATE items SET packed = 1 - packed WHERE id = :id" />
<span class="box" aria-hidden="true"></span>
<span class="what" data-text="what"></span>
<span class="in" data-when="packed">packed</span>
</label>
</template>
</dai-rows>
</section>
<dai-form run="INSERT INTO items (kind, what) VALUES (:kind, :what)">
<select name="kind" aria-label="Group">
<option>Beach</option>
<option>Clothes</option>
<option>Kids</option>
</select>
<input name="what" placeholder="Don't forget…" required maxlength="80" />
<button>Add</button>
</dai-form>
<footer>
<dai-save>Save</dai-save>
</footer>
</main>
<script type="module" src="./dai-kit.js"></script>
</body>
</html>
--- 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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Sea and sand: a sun over water. -->
<rect width="100" height="100" rx="22" fill="#0e7fb0"/>
<circle cx="50" cy="40" r="17" fill="#f5d9a8"/>
<path d="M8 70c10-8 20-8 30 0s20 8 30 0 20-8 30 0v22H8z" fill="#f2f8fb" opacity="0.95"/>
<path d="M8 80c10-8 20-8 30 0s20 8 30 0 20-8 30 0" fill="none" stroke="#0e7fb0" stroke-width="4" stroke-linecap="round"/>
</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
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Receipts two people share, and who owes whom" />
<meta name="dai:does" content="Add a receipt the moment you pay, on your own phone" />
<meta name="dai:does" content="Pass it back and forth and every receipt from both of you is kept" />
<meta name="dai:does" content="Shows who has paid more, and by how much" />
<meta name="theme-color" content="#f6f3ec" />
<title>Receipts</title>
<link rel="stylesheet" href="./app.css" />
</head>
<body>
<script type="application/sql">
-- Local seed only. Shared rows are never seeded with SQL.
INSERT OR IGNORE INTO me (id) VALUES (1);
</script>
<!-- Nothing to type into until app.js has started: a form on screen before
then takes typing the app cannot handle yet, and loses it. -->
<p id="opening" class="opening" role="status">Opening…</p>
<main id="app" hidden inert>
<header>
<h1>Receipts</h1>
<label class="me">
<span>I am</span>
<input id="me" autocomplete="off" maxlength="30" placeholder="Your name" />
</label>
</header>
<p id="notice" class="notice" role="status" hidden></p>
<section id="balance" class="balance" aria-live="polite"></section>
<form id="entry" class="entry" autocomplete="off">
<h2 id="entry-title">Add a receipt</h2>
<div class="fields">
<label>Date <input id="spent-on" type="date" required /></label>
<label>Store <input id="store" required maxlength="60" placeholder="Where" /></label>
<label>Amount <input id="amount" required inputmode="decimal" placeholder="0.00" /></label>
<label>Paid by <input id="paid-by" required maxlength="30" /></label>
</div>
<div class="actions">
<button id="save-entry" type="submit">Add</button>
<button id="cancel-edit" type="button" class="quiet" hidden>Cancel</button>
</div>
</form>
<section>
<div class="list-head">
<h2>All receipts</h2>
<button id="share" type="button" class="quiet">Share</button>
</div>
<ul id="list" class="list"></ul>
<p id="empty" class="empty" hidden>No receipts yet. Add the first one above, then share this with whoever you split costs with.</p>
</section>
<dialog id="conflict">
<h2>Changed on two copies</h2>
<p>This receipt was edited on both copies before they met. Keep the version that is right.</p>
<ul id="versions" class="versions"></ul>
<button id="conflict-close" type="button" class="quiet">Decide later</button>
</dialog>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
--- 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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="22" fill="#2f6f4f"/><path d="M30 18h40v64l-6.7-5-6.6 5-6.7-5-6.7 5-6.6-5L30 82z" fill="#f6f3ec"/><rect x="38" y="32" width="24" height="5" rx="2.5" fill="#2f6f4f"/><rect x="38" y="44" width="24" height="5" rx="2.5" fill="#2f6f4f"/><rect x="38" y="56" width="14" height="5" rx="2.5" fill="#2f6f4f"/></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
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Tic-tac-toe with one other person, by message" />
<meta name="dai:does" content="Start a game and send the invite to one person" />
<meta name="dai:does" content="Take turns on your own phones, sending it back and forth" />
<meta name="dai:does" content="Keeps every game you have played together" />
<meta name="theme-color" content="#1d2340" />
<title>Tic-tac-toe</title>
<link rel="stylesheet" href="./app.css" />
</head>
<body>
<script type="application/sql">
-- Local seed only. Shared rows are never seeded with SQL.
INSERT OR IGNORE INTO settings (id) VALUES (1);
</script>
<!-- Nothing to press until app.js has started: a form on screen before then
takes typing the app cannot handle yet, and loses it. -->
<p id="opening" class="opening" role="status">Opening…</p>
<main id="app" hidden inert>
<header>
<h1>Tic-tac-toe</h1>
<select id="game-list" aria-label="Game"></select>
</header>
<p id="notice" class="notice" role="status" hidden></p>
<section id="play" hidden>
<p id="players" class="players"></p>
<p id="status" class="status" aria-live="polite"></p>
<div id="board" class="board" role="grid" aria-label="Board"></div>
<div id="names-conflict" class="panel" hidden>
<p>The player names were changed on both copies. Keep one:</p>
<div id="name-versions" class="choices"></div>
</div>
<div id="collision" class="panel" hidden>
<p id="collision-text"></p>
<div id="collision-choices" class="choices"></div>
</div>
<div id="seat" class="panel" hidden>
<p id="seat-text"></p>
<div class="choices">
<button id="reseat" type="button" hidden>Send a fresh invite</button>
</div>
</div>
<div class="actions">
<button id="invite" type="button">Invite</button>
<button id="rename" type="button" class="quiet">Edit names</button>
<button id="close-match" type="button" class="quiet" hidden>Close match</button>
</div>
<form id="rename-form" class="panel" autocomplete="off" hidden>
<label>X <input id="rename-x" required maxlength="24" /></label>
<label>O <input id="rename-o" required maxlength="24" /></label>
<div class="choices">
<button type="submit">Save names</button>
<button id="rename-cancel" type="button" class="quiet">Cancel</button>
</div>
</form>
</section>
<form id="new-game" class="panel" autocomplete="off">
<h2>New game</h2>
<label>You <input id="you" required maxlength="24" placeholder="Your name" /></label>
<label>Them <input id="them" required maxlength="24" placeholder="Their name" /></label>
<button type="submit">Start — you are X</button>
</form>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
--- 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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="22" fill="#1d2340"/><g stroke="#3a4270" stroke-width="5" stroke-linecap="round"><path d="M40 18v64M60 18v64M18 40h64M18 60h64"/></g><g stroke="#a8a4ff" stroke-width="7" stroke-linecap="round"><path d="M22 22l12 12M34 22L22 34"/></g><circle cx="50" cy="50" r="6.5" fill="none" stroke="#ffffff" stroke-width="6"/><g stroke="#a8a4ff" stroke-width="7" stroke-linecap="round"><path d="M66 66l12 12M78 66L66 78"/></g></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).
The app I want is: 2. Bring the files
A folder, or the zip it gave you. Nothing is uploaded — the file is built here in this tab, which is rather the point.
Drop a folder or a zip here
Or paste a single HTML file
3. Take your file
If your file opens blank
Two things cause almost all of it:
- The code tried to load something from the internet. Fonts, an icon pack, a charting library. Inside a file there is no internet, so it never arrives. Ask your assistant to write it without.
- It used
awaitoutside atype="module"script. That stops the app before it draws anything.
The checks above catch both before you download. If something else goes wrong, open the file, press F12 and look at the Console tab — the error there is usually literal about what is missing.
When you outgrow this page
What this builds is unsigned, and whoever opens it will be told so plainly. That is right for something personal: the file is whole, every part of it is fingerprinted, and a host checks that before it runs anything. What it carries no claim about is who made it.
A page cannot fix that. Signing needs a key you keep, and a web page has nowhere to keep one — a key made for a single build and thrown away signs nothing anyone can check, and would make your own next version look like somebody else's. So when you publish something, and people need to know that you made it and not somebody who altered it later, use a key of your own through the command line tool.
Nothing changes about the file itself — the format is the same either way.