docs: member-app guide teaches plain literal event types (#1086)
Asking a bot to build an app was broken by the namespace migration (#1057):
the member-app guide (served at /api/member-app-guide, the builder's system
prompt) started teaching `const N = namespace("habit")` with computed
`[N.ev("add")]` event-map keys. But the builder's static type extractor only
reads spelled-out "<name>.…" string literals, so a guide-following app
extracted ZERO types — the build failed with "no event types found", or
deployed with an incomplete type list so the app's own events were dropped
client-side and it quietly did nothing.
Rather than teach the builder the namespace shape, take the simplest path:
teach the guide the plain literal form the builder already handles. Every
event type is written out in full — `events: { "habit.add": … }`, the same
string in `ctx.emit({ type: "habit.add" })` and `e.type === "habit.add"`.
No `namespace()`, no `N.ev`/`ctx.ev`, no CSS-class helper: the app declares
one `name` and writes literal "<name>.…" strings. Nothing else changes — the
validator, the framework, and the built-in apps are untouched (they keep
using namespace(); a proper app-wide survey is a separate, later change).
Co-authored-by: Claude <noreply@anthropic.com>
1 file changed,
+47
−49
docs/building-member-apps.md+47 −49
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md| index b2c6d3b..2abf455 100644 |
| --- a/docs/building-member-apps.md |
| +++ b/docs/building-member-apps.md |
| @@ -64,7 +64,7 @@ These fields still do **nothing** in a member app — do not emit them: |
| 1. **Exactly one file**, a **plain ES module** — no TypeScript, no JSX, no |
| `require()`, no build step. |
| 2. **Imports are allowed ONLY from `"../apps.js"` and `"../format.js"`.** |
| - From `../apps.js`: `defineApp`, `namespace`, `addItem`, `updateItem`, |
| + From `../apps.js`: `defineApp`, `addItem`, `updateItem`, |
| `removeItem`, `partitionByGrace`, `DONE_GRACE_MS`, `keyedList`. From |
| `../format.js` (pure |
| text helpers): `tokenizeText`, `richTokens`, `blockTokens`, `firstLink`, |
| @@ -82,13 +82,12 @@ These fields still do **nothing** in a member app — do not emit them: |
| 6. **Never `innerHTML` user content.** Build DOM with `createElement` and set |
| text with `textContent`. `innerHTML` only ever for static in-code strings. |
| 7. **Use `ctx.interval(fn, ms)`, never bare `setInterval`, inside `render`.** |
| -8. **Every event type is prefixed with the app's `name`.** `name: "habit"` → |
| - types `habit.add`, `habit.tick`. Lowercase; only letters, digits, `_`, `-` |
| - and `.` after the prefix. Declare the prefix once with `namespace(name)` and |
| - derive both event types and CSS classes from it, so it can't drift: |
| - `const N = namespace("habit")` → `N.ev("add")` is `"habit.add"`, `N.cls("row")` |
| - is `"habit-row"`. Inside `render`/`commands` the same helper is `ctx.ev` / |
| - `ctx.cls`. (The prefix must equal `name` — the sandbox validates it.) |
| +8. **Every event type is a plain string prefixed with the app's `name`.** |
| + `name: "habit"` → write the types out in full: `"habit.add"`, `"habit.tick"`. |
| + Use that same quoted string as the `events` map key, in every `ctx.emit`, and |
| + in every `e.type === "habit.add"` comparison. Lowercase; only letters, digits, |
| + `_`, `-` and `.` after the prefix. (Keep it simple — write the literal string; |
| + don't reach for a helper to build it.) |
| 9. **Reducers are pure, immutable, and idempotent by entity id.** Folding the |
| same add twice must produce one item (the collection helpers guarantee it). |
| 10. **Never carry app data in `name`, `ts`, or `uid`** — the transport |
| @@ -113,14 +112,13 @@ These fields still do **nothing** in a member app — do not emit them: |
| ## The shape of an app: `defineApp(spec)` |
| ```js |
| -import { defineApp, namespace, addItem, removeItem } from "../apps.js"; |
| -const N = namespace("habit"); // one prefix declaration — see Namespacing |
| +import { defineApp, addItem, removeItem } from "../apps.js"; |
| export const app = defineApp({ /* fields below */ }); |
| ``` |
| | field | required | what it is | |
| | -------------- | -------- | ---------- | |
| -| `name` | ✓ | Unique lowercase id (letters, digits, dashes; starts with a letter; ≤ 24 chars). Also the prefix for your event types + CSS classes (via `namespace(name)`). | |
| +| `name` | ✓ | Unique lowercase id (letters, digits, dashes; starts with a letter; ≤ 24 chars). Also the prefix for your event types (e.g. `"habit.add"`). | |
| | `title` | ✓ | Human name shown in the Apps list (≤ 40 chars). | |
| | `glyph` | ✓ | One emoji used as the app's avatar. | |
| | `initialState` | ✓ | `() => state`. A **factory**, called to seed the empty state. | |
| @@ -157,32 +155,34 @@ updateItem(list, id, patch) // replace the matching item with { ...item, ...patc |
| removeItem(list, id) // drop the matching item |
| ``` |
| -### Namespacing — declare the prefix once |
| +### Event types — plain, name-prefixed strings |
| -Your event types (`habit.add`, joined with `.`) and CSS classes (`habit-row`, |
| -joined with `-`) all carry the app's name as a prefix. Declare it once with |
| -`namespace(name)` — which **must equal `name`** (the sandbox validates that your |
| -types are name-prefixed) — and derive the rest, so it can't drift: |
| +Every event type is a quoted string that starts with your app's `name` and a |
| +`.`. Write it out in full everywhere it appears — the `events` map key, the |
| +`ctx.emit` type, and any `e.type ===` comparison — so the same literal string |
| +is used throughout: |
| ```js |
| -const N = namespace("habit"); |
| -// events keys: [N.ev("add")] → "habit.add" |
| -// emit / compare: ctx.ev("add") → "habit.add" (ctx.ev === N.ev) |
| -// css classes: ctx.cls("row") → "habit-row" (the styles blob stays literal) |
| +name: "habit", |
| +events: { |
| + "habit.add": (s, e) => …, |
| + "habit.remove": (s, e) => …, |
| +}, |
| ``` |
| -Use `N.ev(...)` for the `events` map keys and `ctx.ev(...)` / `ctx.cls(...)` in |
| -`render`/`commands`. It's optional (leave it off and it defaults to `name`), but |
| -it's the recommended way to satisfy hard constraint 8. |
| +That's the whole rule: one app `name`, and event types are `"<name>.<thing>"` |
| +literals. CSS classes follow the same idea by hand — `"habit-row"` in both the |
| +`styles` blob and the `className` you set. |
| ## Emitting events |
| -`ctx.emit(event)` — the event needs a `type` and whatever your reducer reads. |
| -**Mint `ctx.id()` once for a new entity** and reuse that id in follow-ups: |
| +`ctx.emit(event)` — the event needs a `type` (the full `"<name>.…"` string) and |
| +whatever your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse |
| +that id in follow-ups: |
| ```js |
| -ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "habit.add" |
| -ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "habit.remove" |
| +ctx.emit({ type: "habit.add", id: ctx.id(), text }); |
| +ctx.emit({ type: "habit.remove", id: item.id }); |
| ``` |
| A form is a common input; wire its `onsubmit` (native submission does nothing |
| @@ -196,7 +196,7 @@ form.onsubmit = (e) => { |
| e.preventDefault(); // the runtime also blocks it, but be explicit |
| const text = input.value.trim(); |
| if (!text) return; |
| - ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "habit.add" |
| + ctx.emit({ type: "habit.add", id: ctx.id(), text }); |
| input.value = ""; // the list itself redraws when the event folds back |
| }; |
| el.appendChild(form); |
| @@ -219,7 +219,6 @@ fold redraw. |
| | `ctx.unschedule(sid)` | Cancel a pending scheduled event. | |
| | `ctx.emitEphemeral(event)` | Relay-only delivery — never stored, never folded. | |
| | `ctx.id()` | A fresh unique id for a new entity. | |
| -| `ctx.ev(name)` / `ctx.cls(name)` | Your event type / CSS class from a short name — `ctx.ev("add")` → `"habit.add"`, `ctx.cls("row")` → `"habit-row"` (see Namespacing). | |
| | `ctx.me` / `ctx.meName` | The member's user id / screen name. | |
| | `ctx.getState()` | The latest folded state. | |
| | `ctx.threadId` | The app's thread id. | |
| @@ -232,10 +231,10 @@ For time-based alerts, put a `notice` on the **scheduled** event: |
| ```js |
| const id = ctx.id(); |
| const sid = await ctx.schedule( |
| - { type: ctx.ev("remind"), id, notice: { title: "💧 Water break", body: "Time for a glass." } }, |
| + { type: "habit.remind", id, notice: { title: "💧 Water break", body: "Time for a glass." } }, |
| Date.now() + 60 * 60 * 1000, |
| ); |
| -ctx.emit({ type: ctx.ev("set"), id, sid: sid || "" }); |
| +ctx.emit({ type: "habit.set", id, sid: sid || "" }); |
| ``` |
| ## Commands — quick capture from any chat |
| @@ -251,7 +250,7 @@ commands: { |
| "/habit": (args, ctx) => { |
| const name = args.trim(); |
| if (!name) return ctx.reply({ text: "Usage: /habit <name> — e.g. /habit drink water" }); |
| - ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text: name }); |
| + ctx.emit({ type: "habit.add", id: ctx.id(), text: name }); |
| ctx.reply({ text: `Tracking “${name}”.`, short: `tracking ${name}` }); |
| }, |
| }, |
| @@ -282,7 +281,7 @@ commands: { |
| run: (args, ctx) => { // args is still the raw string |
| const name = args.trim(); |
| if (!name) return ctx.reply({ text: "Usage: /habit <name>" }); |
| - ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text: name }); |
| + ctx.emit({ type: "habit.add", id: ctx.id(), text: name }); |
| ctx.reply({ text: `Tracking “${name}”.` }); |
| }, |
| }, |
| @@ -522,11 +521,7 @@ habit tracker: add habits, tick them off today, streaks derived in render. |
| ```js |
| // habits.js |
| -import { defineApp, namespace, addItem, updateItem, removeItem } from "../apps.js"; |
| - |
| -// One declaration of the prefix — N.ev("add") → "habit.add" (event type), |
| -// N.cls("row") → "habit-row" (CSS class). See Namespacing above. |
| -const N = namespace("habit"); |
| +import { defineApp, addItem, updateItem, removeItem } from "../apps.js"; |
| const DAY = 24 * 60 * 60 * 1000; |
| const dayOf = (ts) => Math.floor(ts / DAY); |
| @@ -538,19 +533,21 @@ export const app = defineApp({ |
| initialState: () => ({ items: [] }), |
| + // Event types are plain "<name>.…" strings — used here as keys, and again in |
| + // the ctx.emit calls in render. |
| events: { |
| - [N.ev("add")]: (s, e) => |
| + "habit.add": (s, e) => |
| e.id && typeof e.text === "string" && e.text.trim() |
| ? { items: addItem(s.items, { id: e.id, text: e.text.trim(), ticks: [] }) } |
| : s, |
| - [N.ev("tick")]: (s, e) => ({ |
| + "habit.tick": (s, e) => ({ |
| items: s.items.map((h) => { |
| if (h.id !== e.id) return h; |
| const d = dayOf(e.ts || 0); |
| return h.ticks.includes(d) ? h : { ...h, ticks: [...h.ticks, d].slice(-400) }; |
| }), |
| }), |
| - [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }), |
| + "habit.remove": (s, e) => ({ items: removeItem(s.items, e.id) }), |
| }, |
| styles: ` |
| @@ -565,7 +562,7 @@ export const app = defineApp({ |
| render(state, el, ctx) { |
| const items = (state && state.items) || []; |
| const form = document.createElement("form"); |
| - form.className = ctx.cls("form"); // "habit-form" |
| + form.className = "habit-form"; |
| const input = document.createElement("input"); |
| input.placeholder = "Add a habit…"; |
| const add = document.createElement("button"); |
| @@ -574,7 +571,7 @@ export const app = defineApp({ |
| form.onsubmit = (ev) => { |
| ev.preventDefault(); |
| const text = input.value.trim(); |
| - if (text) ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); |
| + if (text) ctx.emit({ type: "habit.add", id: ctx.id(), text }); |
| input.value = ""; |
| }; |
| el.appendChild(form); |
| @@ -582,23 +579,23 @@ export const app = defineApp({ |
| const today = dayOf(Date.now()); |
| for (const h of items) { |
| const row = document.createElement("div"); |
| - row.className = ctx.cls("row"); // "habit-row" |
| + row.className = "habit-row"; |
| const done = h.ticks.includes(today); |
| const tick = document.createElement("button"); |
| tick.textContent = done ? "✅" : "⬜"; |
| tick.disabled = done; |
| - tick.onclick = () => ctx.emit({ type: ctx.ev("tick"), id: h.id }); |
| + tick.onclick = () => ctx.emit({ type: "habit.tick", id: h.id }); |
| const name = document.createElement("span"); |
| - name.className = ctx.cls("name"); // "habit-name" |
| + name.className = "habit-name"; |
| name.textContent = h.text; // user content → textContent, never innerHTML |
| let streak = 0; |
| for (let d = today; h.ticks.includes(d); d--) streak++; |
| const st = document.createElement("span"); |
| - st.className = ctx.cls("streak"); // "habit-streak" |
| + st.className = "habit-streak"; |
| st.textContent = streak ? `${streak}🔥` : ""; |
| const rm = document.createElement("button"); |
| rm.textContent = "×"; |
| - rm.onclick = () => ctx.emit({ type: ctx.ev("remove"), id: h.id }); |
| + rm.onclick = () => ctx.emit({ type: "habit.remove", id: h.id }); |
| row.append(tick, name, st, rm); |
| el.appendChild(row); |
| } |
| @@ -611,7 +608,8 @@ export const app = defineApp({ |
| 1. **Idempotent fold** — same event twice, same state (dedupe on entity id). |
| 2. **Pure, immutable reducers** — spread, never mutate. |
| 3. **Don't set `name`/`ts`/`uid` on emit.** |
| -4. **Namespace event types** with the app `name` — `namespace(name)` derives them. |
| +4. **Prefix every event type** with the app `name` — write them as plain |
| + `"<name>.…"` strings. |
| 5. **Never `innerHTML` user content.** |
| 6. **Tolerate `undefined` state in render** (first open). |
| 7. **A scheduled event arrives later, into different state** — its fold must |