eleven / messenger Public

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>
James Adam 0b50a9d2bba7399594dc60d6fe809520c470ddb2 parent acb4636
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