| 1 | <!doctype html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="utf-8" /> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 6 | <title>lchat mini-apps — developer architecture</title> |
| 7 | </head> |
| 8 | <body> |
| 9 | <style> |
| 10 | :root { |
| 11 | --paper: #f7f9f8; --ink: #1b2426; --muted: #5c6b6d; |
| 12 | --accent: #0e7c7b; --accent-soft: #0e7c7b14; |
| 13 | --amber: #a8721c; --amber-soft: #a8721c14; |
| 14 | --blob-bg: #e4e9e8; --blob-ink: #7a8688; |
| 15 | --rule: #d4dcda; --card: #ffffff; --pin: #0e7c7b; |
| 16 | --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; |
| 17 | --serif: Charter, "Bitstream Charter", "Iowan Old Style", Georgia, serif; |
| 18 | --sans: -apple-system, "Segoe UI", system-ui, sans-serif; |
| 19 | } |
| 20 | @media (prefers-color-scheme: dark) { |
| 21 | :root { |
| 22 | --paper: #10171a; --ink: #dce7e4; --muted: #8fa1a0; |
| 23 | --accent: #2fb5a0; --accent-soft: #2fb5a01f; |
| 24 | --amber: #d9a03f; --amber-soft: #d9a03f1c; |
| 25 | --blob-bg: #1d2a2e; --blob-ink: #5e7275; |
| 26 | --rule: #24343a; --card: #162226; --pin: #2fb5a0; |
| 27 | } |
| 28 | } |
| 29 | :root[data-theme="dark"] { |
| 30 | --paper: #10171a; --ink: #dce7e4; --muted: #8fa1a0; --accent: #2fb5a0; |
| 31 | --accent-soft: #2fb5a01f; --amber: #d9a03f; --amber-soft: #d9a03f1c; |
| 32 | --blob-bg: #1d2a2e; --blob-ink: #5e7275; --rule: #24343a; --card: #162226; --pin: #2fb5a0; |
| 33 | } |
| 34 | :root[data-theme="light"] { |
| 35 | --paper: #f7f9f8; --ink: #1b2426; --muted: #5c6b6d; --accent: #0e7c7b; |
| 36 | --accent-soft: #0e7c7b14; --amber: #a8721c; --amber-soft: #a8721c14; |
| 37 | --blob-bg: #e4e9e8; --blob-ink: #7a8688; --rule: #d4dcda; --card: #ffffff; --pin: #0e7c7b; |
| 38 | } |
| 39 | html { background: var(--paper); } |
| 40 | body { margin: 0; color: var(--ink); background: var(--paper); font: 17px/1.65 var(--serif); } |
| 41 | .wrap { max-width: 760px; margin: 0 auto; padding: 0 24px 96px; } |
| 42 | header.top { padding: 64px 0 8px; } |
| 43 | .eyebrow { font: 600 12px/1 var(--sans); letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); margin: 0 0 14px; } |
| 44 | h1 { font: 700 clamp(30px, 5vw, 40px)/1.12 var(--sans); letter-spacing: -0.015em; margin: 0 0 14px; text-wrap: balance; } |
| 45 | .thesis { font-size: 19px; line-height: 1.55; color: var(--muted); margin: 0; text-wrap: balance; } |
| 46 | .thesis strong { color: var(--ink); font-weight: 600; } |
| 47 | h2 { font: 700 24px/1.25 var(--sans); letter-spacing: -0.01em; margin: 64px 0 12px; text-wrap: balance; } |
| 48 | h3 { font: 650 17px/1.3 var(--sans); margin: 30px 0 8px; } |
| 49 | p { margin: 0 0 16px; } |
| 50 | code { font: 0.85em var(--mono); background: var(--accent-soft); padding: 1px 5px; border-radius: 4px; } |
| 51 | pre { |
| 52 | font: 12.5px/1.6 var(--mono); background: var(--card); border: 1px solid var(--rule); |
| 53 | border-radius: 10px; padding: 14px 16px; overflow-x: auto; margin: 16px 0; |
| 54 | } |
| 55 | pre code { background: transparent; padding: 0; } |
| 56 | .caption { font: 13px/1.6 var(--sans); color: var(--muted); margin: 10px 0 0; } |
| 57 | .diagram { margin: 26px 0 6px; } |
| 58 | |
| 59 | /* ---- layered system map ---- */ |
| 60 | .layers { display: flex; flex-direction: column; gap: 0; } |
| 61 | .layer { border: 1px solid var(--rule); border-radius: 12px; background: var(--card); padding: 14px 18px; } |
| 62 | .layer + .layer { margin-top: 26px; position: relative; } |
| 63 | .layer + .layer::before { |
| 64 | content: "⇅"; position: absolute; top: -24px; left: 50%; transform: translateX(-50%); |
| 65 | color: var(--muted); font: 14px var(--sans); background: var(--paper); padding: 0 8px; |
| 66 | } |
| 67 | .layer .ltitle { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; } |
| 68 | .layer .ltitle .nm { font: 700 14px/1 var(--sans); } |
| 69 | .layer .ltitle .file { font: 11.5px var(--mono); color: var(--muted); } |
| 70 | .cells { display: flex; flex-wrap: wrap; gap: 6px; } |
| 71 | .cell { |
| 72 | font: 12px/1.4 var(--sans); background: var(--accent-soft); color: var(--ink); |
| 73 | border-radius: 7px; padding: 4px 9px; |
| 74 | } |
| 75 | .cell.dim { background: var(--blob-bg); color: var(--blob-ink); } |
| 76 | .cell.warn { background: var(--amber-soft); color: var(--amber); } |
| 77 | .boundary-line { |
| 78 | display: flex; align-items: center; gap: 12px; margin: 26px 0; color: var(--accent); |
| 79 | font: 600 11px/1 var(--sans); letter-spacing: 0.12em; text-transform: uppercase; |
| 80 | } |
| 81 | .boundary-line::before, .boundary-line::after { content: ""; flex: 1; border-top: 2px dashed var(--accent); } |
| 82 | |
| 83 | /* ---- fold pipeline ---- */ |
| 84 | .pipeline { display: flex; align-items: stretch; overflow-x: auto; padding-bottom: 6px; } |
| 85 | .stage { |
| 86 | flex: 1 1 0; min-width: 130px; background: var(--card); border: 1px solid var(--rule); |
| 87 | border-radius: 10px; padding: 12px 14px; position: relative; |
| 88 | } |
| 89 | .stage + .stage { margin-left: 34px; } |
| 90 | .stage + .stage::before { |
| 91 | content: "→"; position: absolute; left: -26px; top: 50%; transform: translateY(-50%); |
| 92 | color: var(--muted); font: 400 17px var(--sans); |
| 93 | } |
| 94 | .stage .k { font: 600 13px/1.3 var(--sans); display: block; margin-bottom: 4px; } |
| 95 | .stage .v { font: 11.5px/1.5 var(--mono); color: var(--muted); display: block; overflow-wrap: anywhere; } |
| 96 | |
| 97 | /* ---- chat translation flow (two doorways) ---- */ |
| 98 | .doorways { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } |
| 99 | .door { background: var(--card); border: 1px solid var(--rule); border-radius: 12px; padding: 14px 16px; } |
| 100 | .door h4 { margin: 0 0 10px; font: 600 12px/1 var(--sans); letter-spacing: 0.1em; text-transform: uppercase; } |
| 101 | .door.in h4 { color: var(--accent); } |
| 102 | .door.out h4 { color: var(--amber); } |
| 103 | .flowrow { display: flex; align-items: center; gap: 8px; margin: 7px 0; font: 12px/1.45 var(--mono); flex-wrap: wrap; } |
| 104 | .flowrow .src { background: var(--blob-bg); color: var(--blob-ink); border-radius: 6px; padding: 3px 8px; } |
| 105 | .flowrow .arr { color: var(--muted); font-family: var(--sans); } |
| 106 | .flowrow .dst { background: var(--accent-soft); border-radius: 6px; padding: 3px 8px; } |
| 107 | .flowrow .dst.warn { background: var(--amber-soft); color: var(--amber); } |
| 108 | @media (max-width: 620px) { .doorways { grid-template-columns: 1fr; } } |
| 109 | |
| 110 | /* ---- lifecycle ---- */ |
| 111 | ol.life { list-style: none; margin: 24px 0 8px; padding: 0; counter-reset: step; } |
| 112 | ol.life li { counter-increment: step; position: relative; padding: 0 0 20px 52px; } |
| 113 | ol.life li::before { |
| 114 | content: counter(step); position: absolute; left: 0; top: 0; width: 30px; height: 30px; |
| 115 | border-radius: 50%; background: var(--accent-soft); color: var(--accent); |
| 116 | font: 700 14px/30px var(--sans); text-align: center; |
| 117 | } |
| 118 | ol.life li:not(:last-child)::after { content: ""; position: absolute; left: 15px; top: 34px; bottom: 4px; width: 1px; background: var(--rule); } |
| 119 | ol.life .who { font: 600 11.5px/1 var(--sans); letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: 4px; } |
| 120 | ol.life p { margin: 0; font-size: 15.5px; } |
| 121 | |
| 122 | /* ---- annotated UI mock ---- */ |
| 123 | .mock { |
| 124 | display: grid; grid-template-columns: 168px 1fr; border: 1px solid var(--rule); |
| 125 | border-radius: 14px; overflow: hidden; background: var(--card); min-height: 320px; |
| 126 | font: 11px/1.35 var(--sans); position: relative; |
| 127 | } |
| 128 | .mock * { box-sizing: border-box; } |
| 129 | .m-side { border-right: 1px solid var(--rule); padding: 10px 8px; display: flex; flex-direction: column; gap: 4px; background: color-mix(in srgb, var(--card) 60%, var(--paper)); } |
| 130 | .m-side .m-title { font-weight: 700; font-size: 11px; padding: 2px 6px 8px; } |
| 131 | .m-row { display: flex; align-items: center; gap: 7px; padding: 5px 6px; border-radius: 8px; position: relative; } |
| 132 | .m-row.active { background: var(--accent-soft); } |
| 133 | .m-ava { width: 22px; height: 22px; border-radius: 50%; background: var(--blob-bg); color: var(--blob-ink); font-size: 12px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; } |
| 134 | .m-row .rn { font-weight: 600; } |
| 135 | .m-row .rp { color: var(--muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 96px; } |
| 136 | .m-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); margin-left: auto; flex: 0 0 auto; } |
| 137 | .m-main { display: flex; flex-direction: column; position: relative; } |
| 138 | .m-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--rule); font-weight: 700; } |
| 139 | .m-feed { flex: 1; padding: 10px 12px; display: flex; flex-direction: column; gap: 5px; justify-content: flex-end; position: relative; } |
| 140 | .m-msg { max-width: 62%; } |
| 141 | .m-msg .bub { background: var(--blob-bg); border-radius: 10px; padding: 5px 9px; display: inline-block; } |
| 142 | .m-msg.me { align-self: flex-end; text-align: right; } |
| 143 | .m-msg.me .bub { background: var(--accent); color: #fff; } |
| 144 | .m-reacts { font-size: 9.5px; color: var(--muted); margin: 2px 4px; } |
| 145 | .m-typing { font-size: 10px; color: var(--muted); padding: 0 4px; } |
| 146 | .m-compose { display: flex; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--rule); align-items: center; position: relative; } |
| 147 | .m-compose .plus { width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--rule); display: flex; align-items: center; justify-content: center; color: var(--muted); flex: 0 0 auto; } |
| 148 | .m-compose .field { flex: 1; border: 1px solid var(--rule); border-radius: 12px; padding: 5px 10px; color: var(--muted); } |
| 149 | .m-toast { |
| 150 | position: absolute; top: 8px; right: 10px; background: var(--ink); color: var(--paper); |
| 151 | border-radius: 8px; padding: 5px 10px; font-size: 10px; opacity: 0.92; |
| 152 | } |
| 153 | .pin { |
| 154 | display: inline-flex; width: 17px; height: 17px; border-radius: 50%; background: var(--pin); |
| 155 | color: #fff; font: 700 10.5px/17px var(--sans); align-items: center; justify-content: center; |
| 156 | flex: 0 0 auto; vertical-align: 1px; |
| 157 | } |
| 158 | .pin.abs { position: absolute; z-index: 3; } |
| 159 | .legend { list-style: none; margin: 18px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } |
| 160 | .legend li { display: flex; gap: 10px; font-size: 15px; } |
| 161 | .legend .pin { margin-top: 3px; } |
| 162 | .legend code { font-size: 0.8em; } |
| 163 | @media (max-width: 560px) { .mock { grid-template-columns: 128px 1fr; } .m-row .rp { max-width: 62px; } } |
| 164 | |
| 165 | /* ---- tables ---- */ |
| 166 | .tablewrap { overflow-x: auto; margin: 20px 0 8px; border: 1px solid var(--rule); border-radius: 10px; } |
| 167 | table { border-collapse: collapse; width: 100%; min-width: 560px; background: var(--card); } |
| 168 | th { font: 600 12px/1.3 var(--sans); letter-spacing: 0.08em; text-transform: uppercase; text-align: left; color: var(--muted); padding: 10px 14px; border-bottom: 1px solid var(--rule); } |
| 169 | td { font: 14px/1.5 var(--serif); padding: 10px 14px; border-bottom: 1px solid var(--rule); vertical-align: top; } |
| 170 | tr:last-child td { border-bottom: 0; } |
| 171 | td code { white-space: nowrap; } |
| 172 | td.learns { color: var(--amber); font-family: var(--sans); font-size: 13.5px; } |
| 173 | .note { background: var(--amber-soft); border-radius: 10px; padding: 14px 18px; font-size: 15.5px; margin: 20px 0; } |
| 174 | .note strong { color: var(--amber); } |
| 175 | footer { margin-top: 72px; font: 13px/1.7 var(--sans); color: var(--muted); } |
| 176 | footer code { background: transparent; padding: 0; color: var(--muted); } |
| 177 | </style> |
| 178 | |
| 179 | <div class="wrap"> |
| 180 | <header class="top"> |
| 181 | <p class="eyebrow">lchat · developer guide</p> |
| 182 | <h1>Mini-apps: how the architecture works</h1> |
| 183 | <p class="thesis">Every app — including chat itself — is a client-side program whose database is <strong>the encrypted thread log</strong>. The server stores, schedules, and fans out ciphertext it can never open. This page maps the moving parts, then follows two real apps through them: <strong>/remind</strong> and <strong>chat</strong>.</p> |
| 184 | </header> |
| 185 | |
| 186 | <h2>1 · The core model</h2> |
| 187 | <p>An app action is a <em>typed event</em>, encrypted under the thread key and sent as an ordinary message. Each client independently <em>folds</em> the event stream through the app's pure reducer. Folds are deterministic, so every device converges on the same state without any state-sync protocol — replaying the log <em>is</em> the sync protocol.</p> |
| 188 | |
| 189 | <div class="diagram pipeline" role="img" aria-label="Pipeline: emit an event, fold it, derive state, render a surface"> |
| 190 | <div class="stage"><span class="k">Event</span><span class="v">{ type: "remind.add", text, when }</span></div> |
| 191 | <div class="stage"><span class="k">Fold</span><span class="v">events["remind.add"](state, e)</span></div> |
| 192 | <div class="stage"><span class="k">State</span><span class="v">{ items: [ … ] }</span></div> |
| 193 | <div class="stage"><span class="k">Surface</span><span class="v">render(state, el, ctx)</span></div> |
| 194 | </div> |
| 195 | <p class="caption">One declaration — the app's <code>events</code> map — is the whole model: its keys are the owned wire types, its values the fold. <code>defineApp</code> derives everything else from it.</p> |
| 196 | |
| 197 | <h2>2 · The system map</h2> |
| 198 | <p>Four layers. The dashed line is the security model: keys exist only above it. Below it, teal cells handle ciphertext; amber cells are the metadata the server genuinely learns (routing, timing, sizes).</p> |
| 199 | |
| 200 | <div class="diagram layers"> |
| 201 | <div class="layer"> |
| 202 | <div class="ltitle"><span class="nm">Apps</span><span class="file">web/static/apps/*.js — one self-contained file each</span></div> |
| 203 | <div class="cells"> |
| 204 | <span class="cell">chat 💬</span><span class="cell">remind ⏰</span><span class="cell">todo ✓</span> |
| 205 | <span class="cell">bookmarks 🔖</span><span class="cell">poll 📊</span> |
| 206 | <span class="cell dim">events map · reducers · render · hooks</span> |
| 207 | </div> |
| 208 | </div> |
| 209 | <div class="layer"> |
| 210 | <div class="ltitle"><span class="nm">Framework</span><span class="file">web/static/apps.js</span></div> |
| 211 | <div class="cells"> |
| 212 | <span class="cell">foldEvent / foldHostEvent</span><span class="cell">snapshots (seed / save)</span> |
| 213 | <span class="cell">keyedList</span><span class="cell">notifyLive</span><span class="cell">commands</span> |
| 214 | <span class="cell">preview / unread hooks</span><span class="cell">ephemeral dispatch</span><span class="cell">ctx</span> |
| 215 | </div> |
| 216 | </div> |
| 217 | <div class="layer"> |
| 218 | <div class="ltitle"><span class="nm">Host</span><span class="file">web/static/chat.js — keys, session, shell</span></div> |
| 219 | <div class="cells"> |
| 220 | <span class="cell">thread keys + crypto</span><span class="cell">WebSocket + reconnect</span> |
| 221 | <span class="cell">sidebar / roster / invites</span><span class="cell">transports (emit · schedule · ephemeral)</span> |
| 222 | <span class="cell">chat host bridge</span><span class="cell">native shell bridge</span> |
| 223 | </div> |
| 224 | </div> |
| 225 | <div class="boundary-line">end-to-end encryption boundary — no key ever crosses</div> |
| 226 | <div class="layer"> |
| 227 | <div class="ltitle"><span class="nm">Server</span><span class="file">main.go · hub.go · miniapps.go · push.go</span></div> |
| 228 | <div class="cells"> |
| 229 | <span class="cell dim">message log (blobs)</span><span class="cell dim">schedule queue (blobs)</span> |
| 230 | <span class="cell dim">state snapshots (blobs)</span><span class="cell dim">attachment blobs</span> |
| 231 | <span class="cell warn">thread ids · timestamps · sizes</span> |
| 232 | <span class="cell warn">reaction emoji + counts</span> |
| 233 | <span class="cell">member-scoped fan-out (WS + push)</span> |
| 234 | </div> |
| 235 | </div> |
| 236 | </div> |
| 237 | <p class="caption">The server can't run app logic — it can't even tell which app a blob belongs to. A personal app-thread is just another <code>self</code> thread whose name is encrypted.</p> |
| 238 | |
| 239 | <h2>3 · Example one: /remind — an app on the primitives</h2> |
| 240 | <p>Reminders exercise all three framework capabilities. <strong>Time:</strong> <code>ctx.schedule(event, when)</code> encrypts the future <code>remind.fire</code> <em>now</em>; the server holds the blob and, at the due moment, moves it into the message log transactionally and fans it out like any post. <strong>Voice:</strong> the event carries an encrypted <code>notice</code>, so every situation alerts. <strong>Memory:</strong> folded state is snapshotted per thread and re-seeded on open, so a reminder list outlives the message window.</p> |
| 241 | |
| 242 | <pre><code>const id = ctx.id(); |
| 243 | const sid = await ctx.schedule( |
| 244 | { type: "remind.fire", id, notice: { title: "⏰ Reminder", body: text } }, |
| 245 | whenMs, |
| 246 | ); |
| 247 | ctx.emit({ type: "remind.add", id, text, when: whenMs, sid: sid || "" });</code></pre> |
| 248 | |
| 249 | <ol class="life diagram"> |
| 250 | <li><span class="who">Client · schedule, then record</span> |
| 251 | <p>The fire event is encrypted and queued (<code>POST /api/schedule</code> → an opaque blob + a timestamp). The <code>remind.add</code> that follows carries the schedule id, so <em>any</em> device can cancel it later.</p></li> |
| 252 | <li><span class="who">Server · hold, deliver</span> |
| 253 | <p>A sweep goroutine moves due blobs into the thread's log in one transaction (a crash can't deliver twice) and broadcasts the standard message frame — a delivered schedule is indistinguishable from a posted message.</p></li> |
| 254 | <li><span class="who">Every device · react by situation</span> |
| 255 | <p>Open on the pane: the list updates, a toast speaks. Open elsewhere: toast + system notification. Closed: Web Push carries the same ciphertext; the service worker decrypts the <code>notice</code>. Offline: it waits in the log and folds silently at next open — no stale re-alert.</p></li> |
| 256 | <li><span class="who">Every device · converge</span> |
| 257 | <p><code>remind.fire</code> folds to <code>fired: true</code> idempotently — replays, racing cancels, and dismissed ids are all no-ops.</p></li> |
| 258 | </ol> |
| 259 | |
| 260 | <h2>4 · Example two: chat — the flagship app</h2> |
| 261 | <p>Chat proves the model can carry the core product. It is the <em>existing</em> chat retooled, twice over: it speaks the wire protocol we already had (the composer emits a classic untyped <code>{ text }</code> payload every client understands), and it renders the classic feed's exact markup, so style.css styles both feeds identically.</p> |
| 262 | <p>Chat needed two ideas beyond the basic model. First, some chat mechanisms are <em>server-enforced</em> — deletes really remove ciphertext (author-verified), reactions are deduped per user — and an append-only fold can neither do that nor be trusted to claim it. So chat's mutations fold through <strong>hostEvents</strong>: handlers the host synthesizes from real server frames, never registered as wire types. A member cannot spoof a delete by posting a typed message.</p> |
| 263 | |
| 264 | <div class="diagram doorways" role="img" aria-label="Chat's two doorways: inbound host translations, outbound bridge calls"> |
| 265 | <div class="door in"> |
| 266 | <h4>Inbound — host translates, app folds</h4> |
| 267 | <div class="flowrow"><span class="src">stored payload {text, attachments, replyTo}</span><span class="arr">→</span><span class="dst">chat.msg</span></div> |
| 268 | <div class="flowrow"><span class="src">WS reaction frame (server-computed)</span><span class="arr">→</span><span class="dst">chat.react</span></div> |
| 269 | <div class="flowrow"><span class="src">WS edit frame (author-verified)</span><span class="arr">→</span><span class="dst">chat.edit</span></div> |
| 270 | <div class="flowrow"><span class="src">WS delete frame (hard-deleted)</span><span class="arr">→</span><span class="dst">chat.delete</span></div> |
| 271 | <div class="flowrow"><span class="src">ephemeral frame (relay-only)</span><span class="arr">→</span><span class="dst">chat.typing hook</span></div> |
| 272 | </div> |
| 273 | <div class="door out"> |
| 274 | <h4>Outbound — app asks, host executes</h4> |
| 275 | <div class="flowrow"><span class="dst">ctx.emit({ text, … })</span><span class="arr">→</span><span class="dst warn">encrypt + WS msg</span></div> |
| 276 | <div class="flowrow"><span class="dst">host.react / host.del / host.edit</span><span class="arr">→</span><span class="dst warn">classic WS frames</span></div> |
| 277 | <div class="flowrow"><span class="dst">host.upload / host.loadBlob</span><span class="arr">→</span><span class="dst warn">encrypt ⇄ blob store</span></div> |
| 278 | <div class="flowrow"><span class="dst">host.loadOlder</span><span class="arr">→</span><span class="dst warn">page + decrypt + fold</span></div> |
| 279 | <div class="flowrow"><span class="dst">host.unfurl</span><span class="arr">→</span><span class="dst warn">OG card (the one URL exception)</span></div> |
| 280 | </div> |
| 281 | </div> |
| 282 | <p class="caption">The bridge is the whole coupling surface between chat-the-app and the host — small enough to list. The app never touches crypto, tokens, or the socket.</p> |
| 283 | |
| 284 | <p>Second, chat's state <em>is</em> the log, so it opts out of snapshots (<code>snapshot: false</code>) and renders incrementally: <code>incremental: true</code> stops the framework wiping its surface, and <code>keyedList</code> reconciles bubbles by key + rev — an append reuses every existing DOM node (scroll, focus, loaded images survive), and a reaction re-renders exactly one bubble.</p> |
| 285 | |
| 286 | <pre><code>defineApp({ |
| 287 | name: "chat", scope: "system", // mounted by the host, not the Apps menu |
| 288 | snapshot: false, // state IS the log |
| 289 | incremental: true, // keyedList reconciliation |
| 290 | events: { "chat.typing": (s) => s }, // wire: relay-only presence |
| 291 | hostEvents: { "chat.msg": …, "chat.react": …, |
| 292 | "chat.edit": …, "chat.delete": … }, // host-only translations |
| 293 | … |
| 294 | })</code></pre> |
| 295 | |
| 296 | <h2>5 · Inviting into a DM: a feature that deliberately isn't an app</h2> |
| 297 | <p>One more flow is worth tracing end to end, precisely <em>because</em> it stays outside the app model even though it lives entirely inside a chat thread. A member (or the admin) can invite a brand-new person straight into a DM — before that person has ever joined the instance. Following it shows where the line between "host" and "app" actually falls.</p> |
| 298 | |
| 299 | <ol class="life diagram"> |
| 300 | <li><span class="who">Inviter's browser · mint</span> |
| 301 | <p>Picks "Invite someone" from New Chat, generates an invite secret <code>T</code> the same way instance invites do (<code>docs/invite-redesign.md</code>), and wraps a fresh thread key under it. <code>POST /api/invites</code> creates, in one transaction: a placeholder <code>users</code> row (no <code>public_key</code> yet), a real <code>dm</code> thread, both members, and a <code>thread_keys</code> row — but <strong>only for the inviter</strong>. The invitee's copy doesn't exist until they claim the invite.</p></li> |
| 302 | <li><span class="who">Every device of the inviter · render immediately</span> |
| 303 | <p>The DM shows up right away, like any other thread — the server never "waits" for the second party. <code>pendingInvitee(thread)</code> is a plain roster lookup: <em>does this DM's other member still have no <code>public_key</code>?</em> Nothing about that fact is stored, replicated, or folded — it's derived fresh from the roster on every render.</p></li> |
| 304 | <li><span class="who">The pane · waiting card, not the feed</span> |
| 305 | <p>While pending, the thread view shows a centered card (name, share link, copy/re-send/cancel) instead of a feed and composer nobody but the inviter could use. Once the invitee opens the link and claims their own <code>thread_keys</code> row, the next message (or the next reconnect) clears the pending flag and the pane becomes a normal DM.</p></li> |
| 306 | </ol> |
| 307 | |
| 308 | <p>Steps 2 and 3 are where this stays a <strong>host</strong> concern rather than an app. <code>chat</code> (§4) is the flagship example of a full-pane thread surface built as an app — so why doesn't <em>this</em> full-pane surface work the same way?</p> |
| 309 | |
| 310 | <div class="diagram doorways" role="img" aria-label="Waiting-card: shell-owned facts and actions vs whichever engine renders the pane"> |
| 311 | <div class="door in"> |
| 312 | <h4>Shell owns (chat.js)</h4> |
| 313 | <div class="flowrow"><span class="src">roster + thread_members</span><span class="arr">→</span><span class="dst">pendingInvitee(t)</span></div> |
| 314 | <div class="flowrow"><span class="src">key-invite wrap/re-wrap</span><span class="arr">→</span><span class="dst">Copy link / Resend</span></div> |
| 315 | <div class="flowrow"><span class="src">POST /api/invites</span><span class="arr">→</span><span class="dst">Cancel invite</span></div> |
| 316 | </div> |
| 317 | <div class="door out"> |
| 318 | <h4>Either engine renders the pane</h4> |
| 319 | <div class="flowrow"><span class="dst">classic feed</span><span class="arr">↔</span><span class="dst warn">.feed / .composer hidden</span></div> |
| 320 | <div class="flowrow"><span class="dst">chat app (§4)</span><span class="arr">↔</span><span class="dst warn">.app-pane hidden</span></div> |
| 321 | <div class="flowrow"><span class="src">.thread-view.waiting</span><span class="arr">→</span><span class="dst">one <code>#pending-banner</code>, either way</span></div> |
| 322 | </div> |
| 323 | </div> |
| 324 | <p class="caption">The same fact has to suppress whichever surface happens to be mounted underneath — the classic feed for a device on <code>useOriginalChat</code>, or the chat app's <code>.app-pane</code> for everyone else. That's genuinely a shell-level concern: it sits <em>above</em> the choice of engine, not inside one of them.</p> |
| 325 | |
| 326 | <pre><code>/* style.css — one flag, two possible surfaces underneath it */ |
| 327 | .thread-view.waiting .feed, |
| 328 | .thread-view.waiting .app-pane, /* added when chatAppFor() stopped excluding pending DMs */ |
| 329 | .thread-view.waiting .composer, |
| 330 | .thread-view.waiting .typing { display: none !important; }</code></pre> |
| 331 | |
| 332 | <p>That <code>.app-pane</code> line is recent. Originally <code>chatAppFor()</code> special-cased a pending DM back to the classic feed outright — the CSS only knew how to hide <code>.feed</code>/<code>.composer</code>, so the chat app's pane would have shown a live composer nobody could use. Rather than teach the chat app about pending state, the fix taught the <em>hide rule</em> about the chat app's mount point, and dropped the special case — pending DMs now render through whichever engine is default, exactly like every other thread.</p> |
| 333 | |
| 334 | <div class="note"><strong>Why not fold this into <code>chat</code>'s own render, the way §4 folds reactions and edits?</strong> Because it isn't event-sourced state. Nothing was <em>posted</em> — there's no wire event, no fold, no two-devices-converge story to write a reducer for. It's a live-computed fact about the roster, exactly as available to a thread using the classic feed as one using the chat app. Modeling it as an app would mean an <code>events: {}</code> app that owns no wire type and exists purely to read ambient host state — the framework's model (replicated log → pure fold → surface) buys nothing there. The one thing worth revisiting: once the classic feed is retired and there's only one engine left to keep in sync with, this could move <em>from</em> a shell-level flag <em>into</em> a branch at the top of the chat app's own <code>render()</code> — reading a new <code>host.pendingInvitee(threadId)</code> fact the same way it already reads <code>host.isGroup</code>/<code>host.agentKind</code> — which would delete the <code>.waiting</code> CSS axis entirely rather than maintain it. Deferred, not done, because it only pays for itself once the classic feed has nothing left to share it with.</div> |
| 335 | |
| 336 | <h2>6 · How apps integrate with the shared shell</h2> |
| 337 | <p>Apps aren't islands — the framework gives them defined sockets into the surrounding product. Every pin below is a hook an app can (but needn't) implement:</p> |
| 338 | |
| 339 | <div class="diagram mock" role="img" aria-label="Annotated lchat UI showing where apps plug into the shell"> |
| 340 | <div class="m-side"> |
| 341 | <div class="m-title">Chats</div> |
| 342 | <div class="m-row active"><span class="m-ava">👥</span><div><div class="rn">Weekend crew</div><div class="rp">📊 Lunch spot?</div></div><span class="m-dot"></span><span class="pin abs" style="right:-8px; top:10px;">2</span></div> |
| 343 | <div class="m-row"><span class="m-ava">💬</span><div><div class="rn">Sam</div><div class="rp">see you then!</div></div></div> |
| 344 | <div class="m-row"><span class="m-ava">⏰</span><div><div class="rn">Reminders</div><div class="rp">3 upcoming</div></div><span class="pin abs" style="right:-8px; top:10px;">1</span></div> |
| 345 | <div class="m-row"><span class="m-ava">✓</span><div><div class="rn">To-do</div><div class="rp">2 left</div></div></div> |
| 346 | </div> |
| 347 | <div class="m-main"> |
| 348 | <div class="m-head">Weekend crew<span class="pin abs" style="right:10px; top:12px;">6</span></div> |
| 349 | <div class="m-feed"> |
| 350 | <div class="m-toast">⏰ Reminder — Call the plumber<span class="pin abs" style="left:-8px; top:4px;">5</span></div> |
| 351 | <div class="m-msg"><span class="bub">who's in for saturday?</span><div class="m-reacts">👍 3 · ❤️ 1<span class="pin" style="margin-left:6px;">4</span></div></div> |
| 352 | <div class="m-msg me"><span class="bub">🎉 count me in</span></div> |
| 353 | <div class="m-typing">Sam is typing…</div> |
| 354 | </div> |
| 355 | <div class="m-compose"><span class="plus">+</span><span class="field">/poll Lunch? | Pizza | Sushi</span><span class="pin abs" style="right:10px; top:14px;">3</span></div> |
| 356 | </div> |
| 357 | </div> |
| 358 | |
| 359 | <ul class="legend"> |
| 360 | <li><span class="pin">1</span><div><strong>Sidebar entries.</strong> A <code>personal</code> app gets its own thread row — a <code>self</code> thread whose <em>encrypted</em> name is the app id, so the server can't tell a Reminders thread from Notes. Title + glyph come from <code>defineApp</code>; the row opens the app full-pane.</div></li> |
| 361 | <li><span class="pin">2</span><div><strong>Previews & unread.</strong> The <code>preview(event)</code> and <code>unread(event)</code> hooks let an app title the sidebar line and light the dot — poll shows <code>📊 <question></code> on create; apps without the hooks stay silent. No host special-casing per message type.</div></li> |
| 362 | <li><span class="pin">3</span><div><strong>Composer.</strong> Slash commands (<code>commands</code>) run from any chat — a personal app's command routes to <em>your</em> app-thread with a confirming toast. The :emoji/@mention typeahead ranks candidates with one shared host function, so the app feed, the original feed, and the native strip agree.</div></li> |
| 363 | <li><span class="pin">4</span><div><strong>Message menus.</strong> <code>messageAction(msg)</code> contributes to every message's hover strip / picker — bookmarks adds “Save link” to any message containing one, filing it encrypted into your own thread.</div></li> |
| 364 | <li><span class="pin">5</span><div><strong>Notifications.</strong> One encrypted <code>notice</code> field speaks everywhere: toast in the open app, system notification when hidden, service-worker push when closed. A <code>notify(event, ctx)</code> hook covers state-dependent wording. Live events only — history refolds never re-alert.</div></li> |
| 365 | <li><span class="pin">6</span><div><strong>Native shell.</strong> In the iOS/macOS webview the shell draws the composer and speaks <code>window.lchatNative</code>; an app that owns a thread's pane (chat) registers handlers via the bridge and posts <code>{replying}/{staged}/{error}</code> back. Same contract, two composers, one code path.</div></li> |
| 366 | </ul> |
| 367 | |
| 368 | <h2>7 · The trust ledger</h2> |
| 369 | <p>Everything an app can reach goes through <code>ctx</code> or the host bridge; this is what each capability costs in metadata. Blobs are always encrypted client-side under the thread key.</p> |
| 370 | |
| 371 | <div class="tablewrap"> |
| 372 | <table> |
| 373 | <tr><th>Capability</th><th>Server surface</th><th>Server learns</th></tr> |
| 374 | <tr><td>Event log</td><td><code>GET/POST /api/messages</code> · WS <code>msg</code></td><td class="learns">thread, time, size</td></tr> |
| 375 | <tr><td>Ephemeral events</td><td>WS <code>msg</code> + <code>ephemeral</code> flag (relay-only)</td><td class="learns">thread, moment — never stored</td></tr> |
| 376 | <tr><td>Scheduling</td><td><code>POST/GET/DELETE /api/schedule</code> + sweep</td><td class="learns">thread, delivery time</td></tr> |
| 377 | <tr><td>State snapshots</td><td><code>GET/PUT /api/threads/{id}/state</code> (versioned)</td><td class="learns">thread, size, write times</td></tr> |
| 378 | <tr><td>Attachments</td><td><code>POST /api/upload</code> · <code>GET /api/blob/{id}</code></td><td class="learns">size</td></tr> |
| 379 | <tr><td>Reactions</td><td>WS <code>react/unreact</code> (server counts)</td><td class="learns">emoji + who reacted (plaintext — pre-app mechanism)</td></tr> |
| 380 | <tr><td>Link previews</td><td><code>GET /api/unfurl</code></td><td class="learns">the URL (the one documented exception)</td></tr> |
| 381 | </table> |
| 382 | </div> |
| 383 | |
| 384 | <div class="note"><strong>Design rule:</strong> don't add an app capability chat couldn't use, and prefer generalising an existing chat mechanism over inventing an app-only parallel — the <code>ephemeral</code> flag generalised the typing frame; <code>hostEvents</code> generalised “server-enforced mutation”. Chat stopped being a special case <em>because</em> the primitives were shaped this way.</div> |
| 385 | |
| 386 | <h2>8 · Invariants that keep it all true</h2> |
| 387 | <p>Short version (the full list lives in <code>web/static/apps/README.md</code>): reducers are pure and idempotent — the window re-folds on every open, so the same add twice must be one item; never set <code>name/ts/uid</code> on emit (the transport injects them); validate events in the reducer, not the emitter — anything a member can send is a legal input and must fold to a no-op at worst; user content renders via <code>textContent</code>, never <code>innerHTML</code>; and an app never touches keys, tokens, or the server — if you're reaching for <code>fetch</code>, the design took a wrong turn.</p> |
| 388 | |
| 389 | <footer> |
| 390 | Authoring guide: <code>web/static/apps/README.md</code> · framework: <code>web/static/apps.js</code> · host + bridge: <code>web/static/chat.js</code> · server: <code>miniapps.go</code>, <code>hub.go</code>, <code>push.go</code> · reference apps: <code>apps/remind.js</code> (primitives), <code>apps/chat.js</code> (flagship) · the chat app is the default; switch a device back via ⋮ menu → “Use original chat” |
| 391 | </footer> |
| 392 | </div> |
| 393 | </body> |
| 394 | </html> |
| 395 | |