eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

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();
243const sid = await ctx.schedule(
244 { type: "remind.fire", id, notice: { title: "⏰ Reminder", body: text } },
245 whenMs,
246);
247ctx.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 &amp; 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>📊 &lt;question&gt;</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