eleven / ocho Public

apps: declare an app namespace; derive event types + CSS classes from it (rebase of #415) (#1057)

* apps: declare an app namespace; derive event types + CSS classes from it

Apps prefix the same string onto their event types ("todo.add", joined
with ".") and CSS classes ("todo-item", joined with "-"). That prefix was
written by hand in every file, so it drifted. Add a one-declaration helper,
`namespace(name)`, that derives both:

  const N = namespace("todo");
  N.ev("add")   // "todo.add"   — events-map keys, emits, e.type compares
  N.cls("item") // "todo-item"  — className / selector strings

- `defineApp` gains a `namespace` field (defaults to `name`, so it's fully
  opt-in and nothing changes for apps that ignore it) and exposes `app.ns`.
- The same resolver is on `ctx` as `ctx.ev` / `ctx.cls`, bound to the app.
- The wire strings are IDENTICAL — the helper only spells the existing
  namespaced type, so the cross-platform event-vector contract is untouched.
- Mirrored into the sandbox SDK (sandbox-frame.js + ALLOWED_IMPORTS + the
  Go validator) so member (user-built) apps get the identical helper.
- todo.js migrated as the worked reference; chat.js deliberately stays
  un-namespaced (it reuses the original feed's .msg/.bubble markup).
- Docs updated (mini-app guide, both authoring guides) + tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BQxpDPGm6aLeBty4icC83

* apps: migrate all built-in apps to the namespace helper; use it throughout the guides

Follow-up to the namespace helper: convert every built-in app to declare its
prefix once with `namespace(name)` and derive event types (N.ev) and CSS
classes (N.cls / ctx.cls) from it, and thread the helper through all the
authoring examples the guides give humans and LLMs.

Apps migrated: poll, remind, bookmarks, board, admin, connect, dnsimple,
setup, welcome, appstudio, chat (todo was the earlier reference).

Notable per-app decisions, all preserving byte-identical wire strings:
- bookmarks: event types are `bookmark.*` (singular) while the app name is
  "bookmarks" — declared `namespace("bookmark")` to match the wire prefix; its
  `bookmarks-` CSS classes stay literal (a single prefix can't spell both).
- dnsimple: event migrated; its `dns-` CSS classes stay literal (shorter than
  the name on purpose).
- chat: event types only (msg/react/edit/delete/typing incl. hostEvents keys);
  its CSS classes are the original feed's shared .msg/.bubble markup and are
  deliberately left untouched.
- admin/connect/appstudio: no owned event types (host-driven) — classes only
  where they match the name prefix; App Studio's STARTER template now teaches
  namespace/ctx.ev/ctx.cls.

Docs: the flagship complete-app examples and the emit/command/schedule/
messageAction snippets in web/static/apps/README.md, docs/building-apps.md
(/api/app-guide) and docs/building-member-apps.md (/api/member-app-guide) now
use the helper, with a Namespacing section in each.

Verified: npm test, event-vector contract, and go test ./... all pass; every
app node --checks; the App Studio STARTER still evaluates in the sandbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BQxpDPGm6aLeBty4icC83

* apps: namespace todo/remind's hand-written glue; fix a rebase bug

todo.js and remind.js are schema-described (schemas/*.json), so their
`events` map keys already come from the schema — no drift there. But the
hand-written glue around a schema app (commands, chatMessage/preview/unread)
still compared against the wire type by hand ("todo.add" etc). Namespace
those too: `const N = namespace(schema.name)`, then N.ev("add") instead of
a second hardcoded "todo.add" — same pattern every other built-in app uses.

Also fixes a real bug this surfaced: the previous commit left todo.js
referencing N.ev(...) in preview()/unread() with no `namespace` import and
no `const N` — undefined at runtime, silently swallowed by appPreview's
try/catch (caught by npm test as a null return instead of the expected
preview string). Declaring N properly fixes it for real, rather than
reverting to the literal string as done previously.

Also corrects a test/apps.test.mjs assertion still pinned to the old
hand-written todo.js's 4 event types (missing todo.move, added to the
schema after this branch forked). Updates the README's namespacing note
now that todo.js/remind.js do use the helper, just only in their
hand-written glue.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 5f04eff7060567e1510d217dc50d33182d29b3ba parent 71f2683
20 files changed, +506 −185
  • docs/building-apps.md +58 −17
  • docs/building-member-apps.md +53 −24
  • internal/bot/appbuild.go +1 −1
  • test/apps.test.mjs +30 −0
  • test/sandbox.test.mjs +32 −0
  • web/static/apps.js +40 −0
  • web/static/apps/README.md +75 −13
  • web/static/apps/admin.js +28 −23
  • web/static/apps/appstudio.js +11 −4
  • web/static/apps/board.js +38 −31
  • web/static/apps/bookmarks.js +13 −7
  • web/static/apps/chat.js +12 −8
  • web/static/apps/connect.js +13 −9
  • web/static/apps/dnsimple.js +9 −3
  • web/static/apps/poll.js +26 −19
  • web/static/apps/remind.js +14 −7
  • web/static/apps/setup.js +10 −7
  • web/static/apps/todo.js +12 −5
  • web/static/apps/welcome.js +10 −7
  • web/static/sandbox-frame.js +21 −0
diff --git a/docs/building-apps.md b/docs/building-apps.md
index eaa6507..6e6b881 100644
--- a/docs/building-apps.md
+++ b/docs/building-apps.md
@@ -136,7 +136,8 @@ export const app = defineApp({ /* fields below */ });
| field | required | what it is |
| -------------- | -------- | ---------- |
-| `name` | ✓ | Unique lowercase id. Also the prefix for your event types. |
+| `name` | ✓ | Unique lowercase id. Also the prefix for your event types + CSS classes. |
+| `namespace` | | The event-type / CSS-class prefix. Defaults to `name`; drives `namespace()`, `ctx.ev`, `ctx.cls` — see Namespacing. Leave off unless it must differ from `name`. |
| `title` | ✓ | Human name (sidebar, header). |
| `glyph` | ✓ | One emoji/character used as the app's avatar. |
| `places` | ✓ | WHERE copies can live — any non-empty subset of `["you", "chat", "space"]`; see the interview above. (Legacy `scope: "personal"` still normalizes in for old sources.) |
@@ -170,10 +171,44 @@ Each handler is `(state, event, meta) => newState`. Rules:
- **No `default` case.** The framework returns state unchanged for any type
your app doesn't handle. Your map lists only what you own.
- **Event types are globally unique.** Prefix every type with your app `name`
- (`note.add`, `poll.vote`).
+ (`note.add`, `poll.vote`) — or let `namespace()` do it (below).
- `meta` carries `{ id, ts }` where `id` is the server message id (used for
dedupe) and `ts` the sender's send time. You rarely need it.
+### Namespacing — declare the prefix once
+
+Your app prepends the same string onto its **event types** (`note.add`, joined
+with `.`) and its **CSS classes** (`note-item`, joined with `-`). Write that
+prefix by hand everywhere and it drifts. `namespace(name)` derives both from one
+declaration:
+
+```js
+import { defineApp, namespace, addItem } from "../apps.js";
+
+const N = namespace("note"); // one declaration
+
+export const app = defineApp({
+ name: "note",
+ events: {
+ [N.ev("add")]: (s, e) => …, // key is "note.add"
+ [N.ev("remove")]: (s, e) => …, // key is "note.remove"
+ },
+ render(state, el, ctx) {
+ const row = document.createElement("div");
+ row.className = ctx.cls("item"); // "note-item" (ctx.cls === N.cls)
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add"
+ },
+});
+```
+
+- **`N.ev(short)`** → `"note.short"` for the `events` keys (`[N.ev("add")]`),
+ `ctx.emit` types, and `e.type === …` comparisons.
+- **`N.cls(short)`** → `"note-short"` for `className` / selectors. The `styles`
+ string stays a plain literal — the helper keeps the JS references in step.
+- Inside `render`/`commands` it's on `ctx` as **`ctx.ev`** / **`ctx.cls`**.
+- It's **optional** (`namespace` defaults to `name`), and a name already
+ containing a `.` passes through untouched, so adopting it is a safe rename.
+
### Collection helpers
Most apps fold into a list of `{ id, … }` items. Import these from
@@ -202,8 +237,8 @@ whatever data your reducer reads. **Mint `ctx.id()` once for a new entity**
and reuse that id in follow-up events (done / remove / vote):
```js
-ctx.emit({ type: "note.add", id: ctx.id(), text }); // new entity → new id
-ctx.emit({ type: "note.remove", id: item.id }); // follow-up → the existing id
+ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" — new entity → new id
+ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "note.remove" — follow-up → existing id
```
Three fields are **added for you** by the transport on every emit — never set
@@ -253,7 +288,7 @@ commands: {
run: (args, ctx) => {
const text = args.trim();
if (!text) return ctx.reply({ text: "Usage: /note <text> — e.g. /note call the plumber" });
- ctx.emit({ type: "note.add", id: ctx.id(), text });
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add"
ctx.reply({ text: `Filed “${text}”.`, short: `filed “${text}”`, actions: [{ label: "View notes", run: ctx.open }] });
},
},
@@ -278,10 +313,10 @@ never what was scheduled.
// Schedule first — the sid is part of the reminder's record.
const id = ctx.id();
const sid = await ctx.schedule(
- { type: "remind.fire", id, notice: { title: "⏰ Reminder", body: text } },
+ { type: ctx.ev("fire"), id, notice: { title: "⏰ Reminder", body: text } }, // "remind.fire"
whenMs,
);
-ctx.emit({ type: "remind.add", id, text, when: whenMs, sid: sid || "" });
+ctx.emit({ type: ctx.ev("add"), id, text, when: whenMs, sid: sid || "" }); // "remind.add"
```
Rules of thumb:
@@ -442,7 +477,7 @@ messageAction(msg) {
label: "Save link",
glyph: "🔖",
run(ctx) {
- ctx.emit({ type: "bookmark.add", id: ctx.id(), url, from: msg.from || "" });
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), url, from: msg.from || "" }); // "bookmark.add"
},
};
},
@@ -553,6 +588,7 @@ From `"../apps.js"`:
| export | what it is |
| ------ | ---------- |
| `defineApp(spec)` | registers the app — call exactly once |
+| `namespace(name)` | one-declaration prefix helper: `N.ev("add")` → `"name.add"` (event type), `N.cls("row")` → `"name-row"` (CSS class) — see Namespacing |
| `addItem(list, item)` | immutable append, deduped by `id` |
| `updateItem(list, id, patch)` | immutable in-place patch |
| `removeItem(list, id)` | immutable removal |
@@ -590,10 +626,14 @@ pane lists them.
```js
// note.js
-import { defineApp, addItem, removeItem } from "../apps.js";
+import { defineApp, namespace, addItem, removeItem } from "../apps.js";
+
+// One declaration of the app's prefix — N.ev("add") → "note.add" (event type),
+// N.cls("item") → "note-item" (CSS class). See Namespacing above.
+const N = namespace("note");
export const app = defineApp({
- name: "note", // unique id, lowercase — also the event-type prefix
+ name: "note", // unique id, lowercase — also the default namespace
title: "Notes", // shown in the sidebar and header
glyph: "📝", // avatar glyph
places: ["you"], // its own sidebar thread, rendered full-pane
@@ -605,8 +645,8 @@ export const app = defineApp({
// event into new state. Handlers must be PURE: return new state, never
// mutate the old. The collection helpers keep adds idempotent.
events: {
- "note.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
- "note.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
+ [N.ev("add")]: (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
+ [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }),
},
// A slash command a user can type in any chat. It emits an event; because
@@ -619,14 +659,15 @@ export const app = defineApp({
run: (args, ctx) => {
const text = args.trim();
if (!text) return ctx.reply({ text: "Usage: /note <text> — e.g. /note call the plumber" });
- ctx.emit({ type: "note.add", id: ctx.id(), text });
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });
ctx.reply({ text: `Filed “${text}”.`, short: `filed “${text}”` });
},
},
},
// The app's CSS travels with it — injected once by the framework. Read the
- // theme's CSS variables so it follows the theme.
+ // theme's CSS variables so it follows the theme. The stylesheet stays a plain
+ // literal; ctx.cls() below keeps the JS references in step with it.
styles: `
.note-item { display: flex; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); }
.note-text { flex: 1 1 auto; overflow-wrap: anywhere; }
@@ -637,13 +678,13 @@ export const app = defineApp({
render(state, el, ctx) {
for (const item of (state && state.items) || []) {
const row = document.createElement("div");
- row.className = "note-item";
+ row.className = ctx.cls("item"); // "note-item"
const text = document.createElement("span");
- text.className = "note-text";
+ text.className = ctx.cls("text"); // "note-text"
text.textContent = item.text; // user content → textContent, never innerHTML
const rm = document.createElement("button");
rm.textContent = "×";
- rm.onclick = () => ctx.emit({ type: "note.remove", id: item.id });
+ rm.onclick = () => ctx.emit({ type: ctx.ev("remove"), id: item.id });
row.append(text, rm);
el.appendChild(row);
}
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md
index 0be364c..b2c6d3b 100644
--- a/docs/building-member-apps.md
+++ b/docs/building-member-apps.md
@@ -64,8 +64,9 @@ 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`, `addItem`, `updateItem`, `removeItem`,
- `partitionByGrace`, `DONE_GRACE_MS`, `keyedList`. From `../format.js` (pure
+ From `../apps.js`: `defineApp`, `namespace`, `addItem`, `updateItem`,
+ `removeItem`, `partitionByGrace`, `DONE_GRACE_MS`, `keyedList`. From
+ `../format.js` (pure
text helpers): `tokenizeText`, `richTokens`, `blockTokens`, `firstLink`,
`youtubeId`, `replySnippet`, `emojiOnlyCount`, `typingSummary`,
`autoGroupTitle`, `formatBytes`. Nothing else — no other paths, no URLs, no
@@ -83,7 +84,11 @@ These fields still do **nothing** in a member app — do not emit them:
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.
+ 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.)
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
@@ -108,13 +113,14 @@ These fields still do **nothing** in a member app — do not emit them:
## The shape of an app: `defineApp(spec)`
```js
-import { defineApp, addItem, removeItem } from "../apps.js";
+import { defineApp, namespace, addItem, removeItem } from "../apps.js";
+const N = namespace("habit"); // one prefix declaration — see Namespacing
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. |
+| `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)`). |
| `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. |
@@ -151,14 +157,32 @@ updateItem(list, id, patch) // replace the matching item with { ...item, ...patc
removeItem(list, id) // drop the matching item
```
+### Namespacing — declare the prefix once
+
+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:
+
+```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)
+```
+
+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.
+
## 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:
```js
-ctx.emit({ type: "habit.add", id: ctx.id(), text });
-ctx.emit({ type: "habit.remove", id: item.id });
+ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "habit.add"
+ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "habit.remove"
```
A form is a common input; wire its `onsubmit` (native submission does nothing
@@ -172,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: "habit.add", id: ctx.id(), text });
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "habit.add"
input.value = ""; // the list itself redraws when the event folds back
};
el.appendChild(form);
@@ -195,6 +219,7 @@ 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. |
@@ -207,10 +232,10 @@ For time-based alerts, put a `notice` on the **scheduled** event:
```js
const id = ctx.id();
const sid = await ctx.schedule(
- { type: "water.remind", id, notice: { title: "💧 Water break", body: "Time for a glass." } },
+ { type: ctx.ev("remind"), id, notice: { title: "💧 Water break", body: "Time for a glass." } },
Date.now() + 60 * 60 * 1000,
);
-ctx.emit({ type: "water.set", id, sid: sid || "" });
+ctx.emit({ type: ctx.ev("set"), id, sid: sid || "" });
```
## Commands — quick capture from any chat
@@ -226,7 +251,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: "habit.add", id: ctx.id(), text: name });
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text: name });
ctx.reply({ text: `Tracking “${name}”.`, short: `tracking ${name}` });
},
},
@@ -257,7 +282,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: "habit.add", id: ctx.id(), text: name });
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text: name });
ctx.reply({ text: `Tracking “${name}”.` });
},
},
@@ -497,7 +522,11 @@ habit tracker: add habits, tick them off today, streaks derived in render.
```js
// habits.js
-import { defineApp, addItem, updateItem, removeItem } from "../apps.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");
const DAY = 24 * 60 * 60 * 1000;
const dayOf = (ts) => Math.floor(ts / DAY);
@@ -510,18 +539,18 @@ export const app = defineApp({
initialState: () => ({ items: [] }),
events: {
- "habit.add": (s, e) =>
+ [N.ev("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,
- "habit.tick": (s, e) => ({
+ [N.ev("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) };
}),
}),
- "habit.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
+ [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }),
},
styles: `
@@ -536,7 +565,7 @@ export const app = defineApp({
render(state, el, ctx) {
const items = (state && state.items) || [];
const form = document.createElement("form");
- form.className = "habit-form";
+ form.className = ctx.cls("form"); // "habit-form"
const input = document.createElement("input");
input.placeholder = "Add a habit…";
const add = document.createElement("button");
@@ -545,7 +574,7 @@ export const app = defineApp({
form.onsubmit = (ev) => {
ev.preventDefault();
const text = input.value.trim();
- if (text) ctx.emit({ type: "habit.add", id: ctx.id(), text });
+ if (text) ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });
input.value = "";
};
el.appendChild(form);
@@ -553,23 +582,23 @@ export const app = defineApp({
const today = dayOf(Date.now());
for (const h of items) {
const row = document.createElement("div");
- row.className = "habit-row";
+ row.className = ctx.cls("row"); // "habit-row"
const done = h.ticks.includes(today);
const tick = document.createElement("button");
tick.textContent = done ? "✅" : "⬜";
tick.disabled = done;
- tick.onclick = () => ctx.emit({ type: "habit.tick", id: h.id });
+ tick.onclick = () => ctx.emit({ type: ctx.ev("tick"), id: h.id });
const name = document.createElement("span");
- name.className = "habit-name";
+ name.className = ctx.cls("name"); // "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 = "habit-streak";
+ st.className = ctx.cls("streak"); // "habit-streak"
st.textContent = streak ? `${streak}🔥` : "";
const rm = document.createElement("button");
rm.textContent = "×";
- rm.onclick = () => ctx.emit({ type: "habit.remove", id: h.id });
+ rm.onclick = () => ctx.emit({ type: ctx.ev("remove"), id: h.id });
row.append(tick, name, st, rm);
el.appendChild(row);
}
@@ -582,7 +611,7 @@ 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`.
+4. **Namespace event types** with the app `name` — `namespace(name)` derives them.
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
diff --git a/internal/bot/appbuild.go b/internal/bot/appbuild.go
index 194d52d..814c213 100644
--- a/internal/bot/appbuild.go
+++ b/internal/bot/appbuild.go
@@ -69,7 +69,7 @@ var (
// allowedAppImports mirrors ALLOWED_IMPORTS in web/static/sandbox-frame.js —
// the sandbox SDK surface (drift-pinned by TestAppImportsMatchSandbox).
var allowedAppImports = []string{
- "defineApp", "addItem", "updateItem", "removeItem",
+ "defineApp", "namespace", "addItem", "updateItem", "removeItem",
"partitionByGrace", "DONE_GRACE_MS", "keyedList",
}
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 8c5b18d..18a165b 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -10,6 +10,7 @@ import {
appStateFor,
resetThread,
defineApp,
+ namespace,
setEmitTransport,
setScheduleTransport,
setEphemeralTransport,
@@ -119,6 +120,35 @@ async function test(name, fn) {
}
}
+// ---- namespace helper ---------------------------------------------------
+
+await test("namespace() resolves event types with '.' and CSS classes with '-'", () => {
+ const N = namespace("todo");
+ assert.equal(N.ns, "todo");
+ assert.equal(N.ev("add"), "todo.add");
+ assert.equal(N.cls("item"), "todo-item");
+ assert.equal(N.cls("list--done"), "todo-list--done"); // modifier suffix rides along
+ // A name already carrying a "." passes through untouched — a full type or
+ // another app's type still resolves, so migration is a safe find-and-replace.
+ assert.equal(N.ev("chat.msg"), "chat.msg");
+ // Tolerant of a missing/empty base and a nullish name (never throws).
+ assert.equal(namespace("").ev("x"), ".x");
+ assert.equal(N.ev(null), "todo.null");
+});
+
+await test("defineApp defaults namespace to name and exposes app.ns", () => {
+ // todo declared no explicit namespace → it defaults to its name, and the
+ // synthesised types match the wire contract the event vectors pin.
+ assert.equal(todoApp.namespace, "todo");
+ assert.equal(todoApp.ns.ev("add"), "todo.add");
+ assert.deepEqual([...todoApp.types].sort(), ["todo.add", "todo.done", "todo.move", "todo.remove", "todo.reopen"].sort());
+ // An explicit namespace overrides the name (built-in apps only; member apps
+ // are name-prefix validated by the sandbox).
+ const custom = defineApp({ name: "nstest-app", namespace: "ns", initialState: () => ({}), events: { "ns.e": (s) => s } });
+ assert.equal(custom.namespace, "ns");
+ assert.equal(custom.ns.cls("box"), "ns-box");
+});
+
// ---- command parsing ----------------------------------------------------
await test("parseCommand splits a registered command and its args", () => {
diff --git a/test/sandbox.test.mjs b/test/sandbox.test.mjs
index 73d0b54..759c00a 100644
--- a/test/sandbox.test.mjs
+++ b/test/sandbox.test.mjs
@@ -10,6 +10,7 @@ import {
removeItem as hostRemoveItem,
partitionByGrace as hostPartition,
DONE_GRACE_MS as HOST_GRACE,
+ namespace as hostNamespace,
defineApp,
foldEvent,
appStateFor,
@@ -29,6 +30,7 @@ import {
removeItem,
partitionByGrace,
DONE_GRACE_MS,
+ namespace,
ALLOWED_IMPORTS,
ALLOWED_FMT,
stripCode,
@@ -62,6 +64,36 @@ test("sandbox SDK helpers match apps.js behaviour", () => {
partitionByGrace(items, (i) => i.d, 1000, 500),
hostPartition(items, (i) => i.d, 1000, 500),
);
+ // namespace() must behave identically to the in-page one (same wire types + classes).
+ const s = namespace("todo");
+ const h = hostNamespace("todo");
+ assert.deepEqual([s.ns, s.ev("add"), s.cls("item"), s.ev("chat.msg")], [h.ns, h.ev("add"), h.cls("item"), h.ev("chat.msg")]);
+});
+
+test("namespace is an allowed import and works inside a member app", () => {
+ assert.ok(ALLOWED_IMPORTS.includes("namespace"), "namespace must be importable by a member app");
+ // A member app declaring its types via namespace() gets the same owned types as
+ // one that spells them out — the wire strings are identical either way.
+ const src = `
+ import { defineApp, namespace, addItem } from "../apps.js";
+ const N = namespace("habit");
+ const app = defineApp({
+ name: "habit", title: "Habit", glyph: "H",
+ initialState: () => ({ items: [] }),
+ events: {
+ [N.ev("add")]: (s, e) => ({ items: addItem(s.items, { id: e.id, text: e.text }) }),
+ [N.ev("tick")]: (s) => s,
+ },
+ render() {},
+ });
+ `;
+ const spec = evalMemberApp(src, "habit");
+ assert.deepEqual(spec.types.sort(), ["habit.add", "habit.tick"].sort());
+ assert.equal(spec.ns.ev("add"), "habit.add");
+ assert.equal(spec.ns.cls("row"), "habit-row");
+ // The derived manifest still validates — its types are name-prefixed, as the
+ // sandbox requires (namespace defaults to name).
+ assert.equal(validateManifest(manifestOf(spec)), null);
});
// ---- module transform -------------------------------------------------------
diff --git a/web/static/apps.js b/web/static/apps.js
index 12bbb7b..3882e46 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -171,6 +171,31 @@ injectStyles(
.group-app-root { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }`,
);
+// namespace(ns) is the one-declaration helper an app uses to derive every string
+// it prefixes with its own name — so the prefix lives in ONE place and can never
+// drift (Invariant #4: event types are globally unique, namespaced by app). It
+// returns a tiny resolver:
+// N.ev("add") → "todo.add" — an event TYPE (joined with ".", the wire string
+// that crosses to native — see the event-vector
+// contract; the helper only spells it, it does
+// not change it)
+// N.cls("item")→ "todo-item" — a CSS CLASS name (joined with "-")
+// Use it for the `events` map keys (computed: `[N.ev("add")]: …`), for `ctx.emit`
+// types, for `e.type === N.ev("add")` comparisons, and for className / selector
+// strings in render. `ev` passes a name that already carries a "." through
+// untouched, so a full type ("chat.msg") or another app's type still resolves —
+// which keeps migration a safe find-and-replace. Pure + exported so it's unit
+// tested without a DOM, and mirrored in the sandbox SDK (sandbox-frame.js) so a
+// member app gets the identical helper.
+export function namespace(ns) {
+ const base = String(ns || "");
+ return {
+ ns: base,
+ ev: (name) => (name != null && String(name).includes(".") ? String(name) : base + "." + name),
+ cls: (name) => base + "-" + name,
+ };
+}
+
// defineApp registers a mini-app. Called at module load by each app file. Fields:
// name, title, glyph — identity (title/glyph shown in the sidebar + header)
// places — WHERE the app can live, the one placement declaration
@@ -190,6 +215,8 @@ injectStyles(
// them, #405 — are still accepted as INPUT because stored member-app
// manifests carry them, but normalizePlaces folds them into `places`
// and deletes them: nothing downstream reads a capability flag.)
+// namespace — the prefix for event types + CSS classes (defaults to `name`);
+// reach it as app.ns / ctx.ev / ctx.cls (see namespace() above)
// listed: false — NOT user-instantiable; host-mounted only (the chat feed, the
// setup/welcome conversations, the groupapps registry). Defaults to
// `places.length > 0`. `listed:false` + places ["space"] is the
@@ -260,6 +287,13 @@ export function defineApp(spec) {
// normalizePlaces) — downstream code reads `places` (+ the orthogonal
// `listed` and `quiet`), never a capability flag.
normalizePlaces(app);
+ // The app's namespace drives its event-type and CSS-class prefixes. It defaults
+ // to `name` (the historical convention), so an app that declares nothing keeps
+ // behaving exactly as before; app.ns is the resolver render/commands reach via
+ // ctx.ev / ctx.cls. The module-level namespace() gives the same thing for the
+ // `events` map keys, which are evaluated before this app object exists.
+ app.namespace = app.namespace || app.name;
+ app.ns = namespace(app.namespace);
// Normalise each command to { run, desc, args } so a bare handler function and
// the richer {run,desc,args} form both work — desc/args feed the "/" typeahead.
app.commands = normalizeCommands(app.commands);
@@ -1267,6 +1301,12 @@ function makeCtx(threadId, app) {
// A stable unique id for app entities (todo items, etc). Mint one per new
// entity in the emit; later events (done/remove) reuse that id.
id: () => crypto.randomUUID(),
+ // Namespace resolvers bound to THIS app: ctx.ev("add") → "todo.add",
+ // ctx.cls("item") → "todo-item". The same strings the app's `events` map
+ // declares (via the module-level namespace()) — one source of truth for the
+ // prefix, so a render/command reference can never drift from the fold's keys.
+ ev: app.ns.ev,
+ cls: app.ns.cls,
// The current user's id — for per-user logic (whose vote, etc).
me: currentUser,
// Your screen name — lets an app recognise your own legacy (uid-less)
diff --git a/web/static/apps/README.md b/web/static/apps/README.md
index 6cbdb82..45e94dd 100644
--- a/web/static/apps/README.md
+++ b/web/static/apps/README.md
@@ -72,10 +72,14 @@ sidebar pane lists them.
```js
// web/static/apps/note.js
-import { defineApp, addItem, removeItem } from "../apps.js";
+import { defineApp, namespace, addItem, removeItem } from "../apps.js";
+
+// One declaration of the app's prefix — N.ev("add") is the "note.add" event
+// type, N.cls("item") is the "note-item" CSS class. See "Namespacing" below.
+const N = namespace("note");
export const app = defineApp({
- name: "note", // unique id, lowercase — also the event-type prefix
+ name: "note", // unique id, lowercase — also the default namespace
title: "Notes", // shown in the sidebar and header
glyph: "📝", // avatar glyph
places: ["you"], // where copies can live: just for you (its own sidebar thread)
@@ -89,8 +93,8 @@ export const app = defineApp({
// return new state, never mutate the old. The collection helpers make that
// easy and keep adds idempotent (see "Invariants" below).
events: {
- "note.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
- "note.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
+ [N.ev("add")]: (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, by: e.name || "" }) } : s),
+ [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }),
},
// A slash command a user can type in any chat. It emits an event; because
@@ -99,12 +103,13 @@ export const app = defineApp({
commands: {
"/note": (args, ctx) => {
const text = args.trim();
- if (text) ctx.emit({ type: "note.add", id: ctx.id(), text });
+ if (text) ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text });
},
},
// The app's CSS travels with it — injected once by the framework. Read the
// theme's CSS variables (var(--ink), var(--accent), …) so it follows the theme.
+ // The stylesheet stays a plain literal; ctx.cls() below keeps the JS in step.
styles: `
.note-item { display: flex; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--border); }
.note-text { flex: 1 1 auto; overflow-wrap: anywhere; }
@@ -115,13 +120,13 @@ export const app = defineApp({
render(state, el, ctx) {
for (const item of state.items) {
const row = document.createElement("div");
- row.className = "note-item";
+ row.className = ctx.cls("item"); // "note-item"
const text = document.createElement("span");
- text.className = "note-text";
+ text.className = ctx.cls("text"); // "note-text"
text.textContent = item.text; // user content → textContent, never innerHTML
const rm = document.createElement("button");
rm.textContent = "×";
- rm.onclick = () => ctx.emit({ type: "note.remove", id: item.id });
+ rm.onclick = () => ctx.emit({ type: ctx.ev("remove"), id: item.id });
row.append(text, rm);
el.appendChild(row);
}
@@ -221,6 +226,7 @@ companion that wants *different* group wording implements both, and returning
| field | required | what it is |
| -------------- | -------- | ---------- |
| `name` | ✓ | Unique lowercase id. Also the conventional prefix for your event types. |
+| `namespace` | | The prefix for event types + CSS classes. Defaults to `name` (leave it off unless you need them to differ). Drives the `ctx.ev` / `ctx.cls` helpers and `app.ns` — see [Namespacing](#namespacing-one-prefix-declared-once). |
| `title` | ✓ | Human name (sidebar, header). |
| `glyph` | ✓ | One emoji/character used as the app's avatar. |
| `places` | ✓ | WHERE copies of this app can live — the one placement declaration: any subset of `["you", "chat", "space"]`. `"you"` = a personal, self-thread instance (Apps menu, personal-command routing, `defaultCommand`); `"chat"` = addable to a group chat (⋮ → Apps), one shared instance for everyone there (see "Group-scoped apps"); `"space"` = installable for the WHOLE space, one shared instance in the hidden space thread (see "Space-wide apps" + `docs/space-apps.md`). `[]` = host-mounted/utility. (Formally optional — omitting it means `["you"]`, and the legacy flags/scope normalize into it — but every new app declares it.) |
@@ -630,6 +636,58 @@ Each handler is `(state, event, meta) => newState`. Rules:
- `meta` carries `{ id, ts }` where `id` is the *server message id* (used for
dedupe — see Invariants) and `ts` the sender's send time. You rarely need it.
+### Namespacing: one prefix, declared once
+
+Every app prefixes the same string onto two families of names: its **event
+types** (`board.stroke`, joined with `.` — the wire string, part of the
+[cross-platform contract](#chat-as-an-app--the-default-chat-experience), so the
+helper only *spells* it, it never changes it) and its **CSS classes**
+(`board-canvas`, joined with `-`). Writing that prefix by hand in a dozen places
+is where it drifts. So declare it once with `namespace(name)` and derive the rest:
+
+```js
+import { defineApp, namespace, addItem } from "../apps.js";
+
+const N = namespace("board"); // one declaration of the prefix
+
+export const app = defineApp({
+ name: "board",
+ events: {
+ [N.ev("stroke")]: (s, e) => …, // key is "board.stroke"
+ [N.ev("clear")]: (s, e) => …, // key is "board.clear"
+ },
+ render(state, el, ctx) {
+ const canvas = document.createElement("canvas");
+ canvas.className = ctx.cls("canvas"); // "board-canvas" (ctx.cls === N.cls)
+ ctx.emit({ type: ctx.ev("stroke"), id: ctx.id(), points }); // "board.stroke"
+ if (e.type === N.ev("stroke")) … // compare against the same string
+ },
+});
+```
+
+- **`N.ev(short)`** → `"board.short"` — use it for the `events` map keys
+ (computed: `[N.ev("stroke")]`), every `ctx.emit({ type })`, and every
+ `e.type === …` comparison.
+- **`N.cls(short)`** → `"board-short"` — use it for `className` and selector
+ strings. The `styles` blob stays a plain literal (it's static text); the helper
+ keeps the *JS* references to those classes in lock-step with them.
+- Inside `render`/`commands` the same resolver is on `ctx` as **`ctx.ev`** /
+ **`ctx.cls`**, bound to your app — no module-level `N` needed there.
+- `namespace` **defaults to `name`**, so it's fully opt-in: an app that never
+ calls it behaves exactly as before, and `board.js` is the migrated reference.
+- A name that already contains a `.` passes through untouched
+ (`N.ev("chat.msg")` → `"chat.msg"`), so referencing a full or foreign type
+ still works — which makes converting an existing app a safe find-and-replace.
+
+`board.js` is the worked example. (`chat.js` deliberately does *not* namespace its
+classes — it reuses the original feed's `.msg`/`.bubble` markup on purpose; the
+helper is opt-in precisely so it can. `todo.js`/`remind.js` are schema-described
+(docs/app-schema.md): their `events` map keys come from the JSON schema, not
+`namespace()` — but the hand-written glue around a schema app (commands,
+`chatMessage`/`preview`/`unread`) still compares against the wire type by hand,
+so it namespaces too: `const N = namespace(schema.name)`, then `N.ev("add")`
+instead of a second hardcoded `"todo.add"`.)
+
### Collection helpers
Most apps fold into a list of `{ id, … }` items. Import these from `../apps.js`
@@ -670,8 +728,8 @@ data your reducer reads. **Mint `ctx.id()` once for a new entity** and reuse tha
id in follow-up events (done / remove / vote):
```js
-ctx.emit({ type: "note.add", id: ctx.id(), text }); // new entity → new id
-ctx.emit({ type: "note.remove", id: item.id }); // follow-up → the existing id
+ctx.emit({ type: ctx.ev("add"), id: ctx.id(), text }); // "note.add" — new entity → new id
+ctx.emit({ type: ctx.ev("remove"), id: item.id }); // "note.remove" — follow-up → existing id
```
Three fields are **added for you** by the transport on every emit — don't set
@@ -695,6 +753,8 @@ server.
| `ctx.schedule(event, when)` | anywhere | Have the **server** deliver `event` into the thread at `when` (epoch ms). Returns `Promise<sid \| null>`. |
| `ctx.unschedule(sid)` | anywhere | Cancel a pending scheduled event. |
| `ctx.id()` | anywhere | A fresh unique id for a new entity. |
+| `ctx.ev(name)` | anywhere | Your event type from a short name — `ctx.ev("add")` → `"todo.add"` (see [Namespacing](#namespacing-one-prefix-declared-once)). |
+| `ctx.cls(name)` | anywhere | Your CSS class from a short name — `ctx.cls("item")` → `"todo-item"`. |
| `ctx.me` | anywhere | The current user's id. |
| `ctx.getState()` | anywhere | The app's latest folded state for this thread. |
| `ctx.threadId` | anywhere | The thread the app is acting in. |
@@ -732,10 +792,10 @@ toast it. The server learns only *(thread, time)*, never what was scheduled.
// Schedule first — the sid is part of the reminder's record.
const id = ctx.id();
const sid = await ctx.schedule(
- { type: "remind.fire", id, notice: { title: "⏰ Reminder", body: text } },
+ { type: ctx.ev("fire"), id, notice: { title: "⏰ Reminder", body: text } }, // "remind.fire"
whenMs,
);
-ctx.emit({ type: "remind.add", id, text, when: whenMs, sid: sid || "" });
+ctx.emit({ type: ctx.ev("add"), id, text, when: whenMs, sid: sid || "" }); // "remind.add"
```
Rules of thumb (all visible in `remind.js`, the reference implementation):
@@ -869,7 +929,7 @@ messageAction(msg) {
label: "Save link",
glyph: "🔖",
run(ctx) {
- ctx.emit({ type: "bookmark.add", id: ctx.id(), url, from: msg.from || "" });
+ ctx.emit({ type: ctx.ev("add"), id: ctx.id(), url, from: msg.from || "" });
},
};
},
@@ -1055,6 +1115,8 @@ typing frame) over inventing an app-only parallel.
3. **Don't set `name` / `ts` / `uid` on emit** — the transport injects them.
Setting `ts: Date.now()` is dead code (the transport overwrites it).
4. **Namespace event types** with your app `name`; they're globally unique.
+ `namespace(name)` (see [Namespacing](#namespacing-one-prefix-declared-once))
+ derives them for you so the prefix can't drift.
5. **Never `innerHTML` user content** — `textContent` / DOM nodes only.
6. **Widget-pane apps render an honest empty state** (a one-line hint beats a
blank pane — see poll.js).
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index f876dce..0ce2463 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -10,10 +10,15 @@
// calls of its own. The one thing that DOES live in the thread's event log is
// the update stream: release notes the app posts (admin.note), read in the
// companion Chat view — see announceNew below.
-import { defineApp, setupBridge, registerFloorCommand, openAppThread } from "../apps.js";
+import { defineApp, namespace, setupBridge, registerFloorCommand, openAppThread } from "../apps.js";
import { ICON_WRENCH } from "../icons.js";
import { changelogParagraphs } from "../format.js";
+// One declaration of the app's prefix, reused everywhere: N.cls("root") → the
+// "admin-root" CSS class. Change "admin" here and every class string below
+// follows — the prefix can't drift.
+const N = namespace("admin");
+
// ── Release notes → the Chat view ─────────────────────────────────────────
// In place of the retired Announcer bot, the Admin app POSTS each new release
// note into this thread as an ordinary encrypted event; chatMessage projects it
@@ -212,7 +217,7 @@ export const app = defineApp({
render(state, el, ctx) {
const bridge = setupBridge();
const root = document.createElement("div");
- root.className = "admin-root";
+ root.className = N.cls("root");
el.appendChild(root);
if (!bridge) return;
@@ -222,9 +227,9 @@ export const app = defineApp({
const section = (title) => {
const s = document.createElement("div");
- s.className = "admin-group";
+ s.className = N.cls("group");
const h = document.createElement("h2");
- h.className = "admin-eyebrow";
+ h.className = N.cls("eyebrow");
h.textContent = title;
s.appendChild(h);
root.appendChild(s);
@@ -232,7 +237,7 @@ export const app = defineApp({
};
const statusLine = (parent) => {
const p = document.createElement("div");
- p.className = "admin-status";
+ p.className = N.cls("status");
parent.appendChild(p);
return p;
};
@@ -246,14 +251,14 @@ export const app = defineApp({
// right. Returns the checkbox for wiring.
const switchRow = (parent, text) => {
const row = document.createElement("div");
- row.className = "admin-row";
+ row.className = N.cls("row");
const wrap = document.createElement("label");
- wrap.className = "admin-switch-label";
+ wrap.className = N.cls("switch-label");
const t = document.createElement("span");
- t.className = "admin-switch-text";
+ t.className = N.cls("switch-text");
t.textContent = text;
const sw = document.createElement("span");
- sw.className = "admin-switch";
+ sw.className = N.cls("switch");
const check = document.createElement("input");
check.type = "checkbox";
const knob = document.createElement("span");
@@ -270,18 +275,18 @@ export const app = defineApp({
// name here are exactly what an invited person sees, so edits below show
// their effect the moment you type.
const preview = document.createElement("div");
- preview.className = "admin-preview";
+ preview.className = N.cls("preview");
const previewGlyph = document.createElement("div");
- previewGlyph.className = "admin-preview-glyph";
+ previewGlyph.className = N.cls("preview-glyph");
previewGlyph.textContent = "💬";
const previewPhoto = document.createElement("img");
- previewPhoto.className = "admin-preview-photo";
+ previewPhoto.className = N.cls("preview-photo");
previewPhoto.alt = "";
previewPhoto.style.display = "none";
const previewTitle = document.createElement("div");
- previewTitle.className = "admin-preview-title";
+ previewTitle.className = N.cls("preview-title");
const previewSub = document.createElement("div");
- previewSub.className = "admin-preview-sub";
+ previewSub.className = N.cls("preview-sub");
previewSub.textContent = "Private, end-to-end encrypted chat.";
// The space description renders on the real join card (below the sub line),
// so the miniature mirrors it — live as the admin types, hidden when empty.
@@ -291,7 +296,7 @@ export const app = defineApp({
preview.append(previewGlyph, previewPhoto, previewTitle, previewSub, previewDesc);
root.appendChild(preview);
const previewCaption = document.createElement("div");
- previewCaption.className = "admin-preview-caption";
+ previewCaption.className = N.cls("preview-caption");
previewCaption.textContent = "What people see when you invite them";
root.appendChild(previewCaption);
@@ -316,7 +321,7 @@ export const app = defineApp({
// ---- This space: name + contact, saved together -------------------------
const space = section("This space");
const nameRow = document.createElement("div");
- nameRow.className = "admin-row";
+ nameRow.className = N.cls("row");
const nameLabel = document.createElement("label");
nameLabel.textContent = "Name";
const nameInput = document.createElement("input");
@@ -345,7 +350,7 @@ export const app = defineApp({
descInput.addEventListener("input", paintPreviewDesc);
const emailRow = document.createElement("div");
- emailRow.className = "admin-row";
+ emailRow.className = N.cls("row");
const emailLabel = document.createElement("label");
emailLabel.textContent = "Contact email";
const emailInput = document.createElement("input");
@@ -354,7 +359,7 @@ export const app = defineApp({
space.appendChild(emailRow);
const saveRow = document.createElement("div");
- saveRow.className = "admin-row admin-row-end";
+ saveRow.className = `${N.cls("row")} ${N.cls("row-end")}`;
const save = document.createElement("button");
save.textContent = "Save";
saveRow.appendChild(save);
@@ -1210,7 +1215,7 @@ export const app = defineApp({
const autoCheck = switchRow(updSec, "Install updates automatically");
const dlCheck = switchRow(updSec, "Download new versions automatically");
const chanRow = document.createElement("div");
- chanRow.className = "admin-row";
+ chanRow.className = N.cls("row");
const chanLabel = document.createElement("label");
chanLabel.textContent = "Channel";
const chanSelect = document.createElement("select");
@@ -1349,9 +1354,9 @@ export const app = defineApp({
// ---- Photo ----------------------------------------------------------------
const photoSec = section("Photo");
const photoRow = document.createElement("div");
- photoRow.className = "admin-row";
+ photoRow.className = N.cls("row");
const photoImg = document.createElement("img");
- photoImg.className = "admin-photo";
+ photoImg.className = N.cls("photo");
photoImg.alt = "";
photoImg.style.display = "none";
const photoPick = document.createElement("button");
@@ -1365,7 +1370,7 @@ export const app = defineApp({
// Alt text lives with the photo (and would otherwise be silently reset by a
// photo change) — editable here without re-uploading the image.
const altRow = document.createElement("div");
- altRow.className = "admin-row";
+ altRow.className = N.cls("row");
altRow.style.display = "none";
const altLabel = document.createElement("label");
altLabel.textContent = "Alt text";
@@ -1453,7 +1458,7 @@ export const app = defineApp({
delegateDesc.textContent = "Create a link someone can use to join as admin, or to claim admin role on their existing account.";
delegateSec.appendChild(delegateDesc);
const delegateRow = document.createElement("div");
- delegateRow.className = "admin-row admin-row-end";
+ delegateRow.className = `${N.cls("row")} ${N.cls("row-end")}`;
const delegateBtn = document.createElement("button");
delegateBtn.textContent = "Create admin invite";
delegateRow.appendChild(delegateBtn);
diff --git a/web/static/apps/appstudio.js b/web/static/apps/appstudio.js
index dd790e9..91e6269 100644
--- a/web/static/apps/appstudio.js
+++ b/web/static/apps/appstudio.js
@@ -10,7 +10,7 @@
// It's a TRUSTED compiled-in app (it holds the token + instance key to
// encrypt/deploy), so the privileged work lives in ../sandbox.js; App Studio
// is just the UI over it. The authoring rules are at GET /api/member-app-guide.
-import { defineApp } from "../apps.js";
+import { defineApp, namespace } from "../apps.js";
import { ICON_PUZZLE } from "../icons.js";
import { ENABLE_MEMBER_APPS } from "../shellstate.js";
import {
@@ -23,6 +23,10 @@ import {
manifestIsLegacy,
} from "../sandbox.js";
+// One declaration of the app's prefix: N.ev("x") → the "appstudio.x" wire type,
+// N.cls("x") → the "appstudio-x" CSS class (reached as ctx.ev / ctx.cls in render).
+const N = namespace("appstudio");
+
// Static in-code SVG (safe for innerHTML — no user content).
const ICON_DOWNLOAD =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
@@ -42,7 +46,9 @@ function downloadSource(source, name) {
URL.revokeObjectURL(url);
}
-const STARTER = `import { defineApp } from "../apps.js";
+const STARTER = `import { defineApp, namespace } from "../apps.js";
+
+const N = namespace("hello"); // N.ev("tap") → "hello.tap", N.cls("btn") → "hello-btn"
export const app = defineApp({
name: "hello", // unique lowercase id — rename for your app
@@ -52,13 +58,14 @@ export const app = defineApp({
initialState: () => ({ count: 0 }),
events: {
- "hello.tap": (s) => ({ count: s.count + 1 }),
+ [N.ev("tap")]: (s) => ({ count: s.count + 1 }),
},
render(state, el, ctx) {
const btn = document.createElement("button");
btn.textContent = "Say hello";
- btn.onclick = () => ctx.emit({ type: "hello.tap" });
+ btn.className = ctx.cls("btn");
+ btn.onclick = () => ctx.emit({ type: ctx.ev("tap") });
el.appendChild(btn);
if (state.count > 0) {
const p = document.createElement("p");
diff --git a/web/static/apps/board.js b/web/static/apps/board.js
index 41d390b..837238a 100644
--- a/web/static/apps/board.js
+++ b/web/static/apps/board.js
@@ -7,9 +7,16 @@
//
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
-import { defineApp, addItem } from "../apps.js";
+import { defineApp, namespace, addItem } from "../apps.js";
import { ICON_PRESENTATION } from "../icons.js";
+// One declaration of the app's prefix, reused everywhere: N.ev("stroke") → the
+// "board.stroke" wire type (used as the `events` keys and in every emit/compare),
+// N.cls("canvas") → the "board-canvas" CSS class. Change "board" here and every
+// string below follows — the prefix can't drift. (Inside render/commands the same
+// helper is on ctx as ctx.ev / ctx.cls.)
+const N = namespace("board");
+
// Points are stored in a fixed logical canvas size, not normalized — every
// device draws the same picture at the same coordinates regardless of its own
// panel width (the DOM canvas is scaled to fit via CSS, pointer positions are
@@ -61,31 +68,31 @@ export const app = defineApp({
openEvent: "board.open",
initialState: () => ({ open: false, strokes: [] }),
events: {
- "board.open": (s) => (s.open ? s : { ...s, open: true }),
- "board.close": (s) => (s.open ? { ...s, open: false } : s),
- "board.stroke": (s, e) => {
+ [N.ev("open")]: (s) => (s.open ? s : { ...s, open: true }),
+ [N.ev("close")]: (s) => (s.open ? { ...s, open: false } : s),
+ [N.ev("stroke")]: (s, e) => {
const points = sanitizePoints(e.points);
if (!e.id || !points) return s;
const color = COLORS.includes(e.color) ? e.color : COLORS[0];
const width = WIDTHS.includes(e.width) ? e.width : WIDTHS[1];
return { ...s, strokes: addItem(s.strokes, { id: e.id, points, color, width }) };
},
- "board.clear": (s) => (s.strokes.length ? { ...s, strokes: [] } : s),
+ [N.ev("clear")]: (s) => (s.strokes.length ? { ...s, strokes: [] } : s),
},
commands: {
// /board opens the whiteboard panel; /board clear wipes it; /board close hides it.
"/board": (args, ctx) => {
const a = args.trim().toLowerCase();
- if (a === "clear") ctx.emit({ type: "board.clear" });
- else if (a === "close") ctx.emit({ type: "board.close" });
- else ctx.emit({ type: "board.open" });
+ if (a === "clear") ctx.emit({ type: N.ev("clear") });
+ else if (a === "close") ctx.emit({ type: N.ev("close") });
+ else ctx.emit({ type: N.ev("open") });
},
},
// Sidebar presence: opening is worth surfacing, like poll's create; strokes
// (and closing) stay quiet — the panel updates live anyway.
- preview: (e) => (e.type === "board.open" ? "🎨 opened the whiteboard" : null),
- unread: (e) => e.type === "board.open",
+ preview: (e) => (e.type === N.ev("open") ? "🎨 opened the whiteboard" : null),
+ unread: (e) => e.type === N.ev("open"),
// This app's CSS travels with it — the framework injects it once. It reads the
// theme's CSS variables (var(--accent) etc.) so it follows the chosen theme.
@@ -147,7 +154,7 @@ export const app = defineApp({
incremental: true,
render(state, el, ctx) {
const s = state || {};
- let wrap = el.querySelector(".board-wrap");
+ let wrap = el.querySelector("." + N.cls("wrap"));
if (!s.open) {
if (wrap) wrap.remove();
return;
@@ -161,7 +168,7 @@ export const app = defineApp({
wrap.classList.remove("minimized");
}
wrap._ctx = ctx;
- paintStrokes(wrap.querySelector(".board-base"), s.strokes || []);
+ paintStrokes(wrap.querySelector("." + N.cls("base")), s.strokes || []);
},
});
@@ -197,19 +204,19 @@ function drawStroke(g, stroke) {
// thread is actually active when the user actually draws/clears/closes.
function buildBoard() {
const wrap = document.createElement("div");
- wrap.className = "board-wrap";
+ wrap.className = N.cls("wrap");
const header = document.createElement("div");
- header.className = "board-header";
+ header.className = N.cls("header");
const title = document.createElement("span");
- title.className = "board-title";
+ title.className = N.cls("title");
title.textContent = "🎨 Whiteboard";
header.appendChild(title);
- header.appendChild(Object.assign(document.createElement("div"), { className: "board-spacer" }));
+ header.appendChild(Object.assign(document.createElement("div"), { className: N.cls("spacer") }));
const minBtn = document.createElement("button");
minBtn.type = "button";
- minBtn.className = "board-min";
+ minBtn.className = N.cls("min");
const setMinLabel = () => {
const min = wrap.classList.contains("minimized");
minBtn.textContent = min ? "Expand" : "Minimize";
@@ -224,22 +231,22 @@ function buildBoard() {
const closeBtn = document.createElement("button");
closeBtn.type = "button";
- closeBtn.className = "board-close-btn";
+ closeBtn.className = N.cls("close-btn");
closeBtn.textContent = "Close";
closeBtn.setAttribute("aria-label", "Close whiteboard");
// Non-destructive: hides the panel (board.close), but the drawing is still
// there — /board reopens it exactly as it was. Nothing to confirm.
- closeBtn.onclick = () => wrap._ctx.emit({ type: "board.close" });
+ closeBtn.onclick = () => wrap._ctx.emit({ type: N.ev("close") });
header.appendChild(closeBtn);
wrap.appendChild(header);
const body = document.createElement("div");
- body.className = "board-body";
+ body.className = N.cls("body");
wrap.appendChild(body);
const toolbar = document.createElement("div");
- toolbar.className = "board-toolbar";
+ toolbar.className = N.cls("toolbar");
let color = COLORS[0];
let width = WIDTHS[1];
@@ -247,7 +254,7 @@ function buildBoard() {
const swatches = COLORS.map((c, i) => {
const b = document.createElement("button");
b.type = "button";
- b.className = "board-swatch" + (i === 0 ? " selected" : "");
+ b.className = N.cls("swatch") + (i === 0 ? " selected" : "");
b.style.background = c;
b.setAttribute("aria-label", "Color " + c);
b.onclick = () => {
@@ -262,9 +269,9 @@ function buildBoard() {
const sizes = WIDTHS.map((w, i) => {
const b = document.createElement("button");
b.type = "button";
- b.className = "board-size" + (i === 1 ? " selected" : "");
+ b.className = N.cls("size") + (i === 1 ? " selected" : "");
const dot = document.createElement("span");
- dot.className = "board-size-dot";
+ dot.className = N.cls("size-dot");
dot.style.width = dot.style.height = 4 + i * 4 + "px";
b.appendChild(dot);
b.setAttribute("aria-label", sizeLabels[i] + " brush");
@@ -276,11 +283,11 @@ function buildBoard() {
return b;
});
- toolbar.appendChild(Object.assign(document.createElement("div"), { className: "board-spacer" }));
+ toolbar.appendChild(Object.assign(document.createElement("div"), { className: N.cls("spacer") }));
const clear = document.createElement("button");
clear.type = "button";
- clear.className = "board-clear";
+ clear.className = N.cls("clear");
clear.textContent = "Clear";
// Two-step inline confirm (see poll.js's Close poll) rather than confirm():
// embedded webviews have no confirm panel, so the dialog would silently
@@ -297,19 +304,19 @@ function buildBoard() {
}
delete clear.dataset.armed;
clear.textContent = "Clear";
- wrap._ctx.emit({ type: "board.clear" });
+ wrap._ctx.emit({ type: N.ev("clear") });
};
toolbar.appendChild(clear);
body.appendChild(toolbar);
const stage = document.createElement("div");
- stage.className = "board-stage";
+ stage.className = N.cls("stage");
const base = document.createElement("canvas");
- base.className = "board-base";
+ base.className = N.cls("base");
base.width = CANVAS_W;
base.height = CANVAS_H;
const live = document.createElement("canvas");
- live.className = "board-live";
+ live.className = N.cls("live");
live.width = CANVAS_W;
live.height = CANVAS_H;
stage.append(base, live);
@@ -347,7 +354,7 @@ function buildBoard() {
const points = drawing.points;
drawing = null;
live.getContext("2d").clearRect(0, 0, CANVAS_W, CANVAS_H);
- if (points.length >= 2) wrap._ctx.emit({ type: "board.stroke", id: wrap._ctx.id(), points, color, width });
+ if (points.length >= 2) wrap._ctx.emit({ type: N.ev("stroke"), id: wrap._ctx.id(), points, color, width });
};
live.addEventListener("pointerup", finishStroke);
live.addEventListener("pointercancel", finishStroke);
diff --git a/web/static/apps/bookmarks.js b/web/static/apps/bookmarks.js
index 0daf029..97d544e 100644
--- a/web/static/apps/bookmarks.js
+++ b/web/static/apps/bookmarks.js
@@ -9,10 +9,16 @@
//
// Relative imports (not "/static/…") so the reducer + link detection are
// exercisable in Node tests while still resolving under /static/ in the browser.
-import { defineApp, addItem, removeItem } from "../apps.js";
+import { defineApp, namespace, addItem, removeItem } from "../apps.js";
import { ICON_BOOKMARK } from "../icons.js";
import { firstLink } from "../format.js";
+// One declaration of the app's event prefix, reused everywhere: N.ev("add") → the
+// "bookmark.add" wire type (used as the `events` keys and in every emit). Note the
+// event prefix is the SINGULAR "bookmark" while the app name + CSS classes are the
+// plural "bookmarks-", so N drives only event types; the classes stay literal below.
+const N = namespace("bookmark");
+
// A URL is accepted as-is when it already has an http(s) scheme; a bare domain
// (has a dot, no spaces) gets https:// prepended; anything else is rejected.
function normalizeUrl(raw) {
@@ -59,13 +65,13 @@ export const app = defineApp({
places: ["you", "chat"],
initialState: () => ({ items: [] }),
events: {
- "bookmark.add": (s, e) => {
+ [N.ev("add")]: (s, e) => {
if (!e.id || !e.url) return s;
const items = addItem(s.items, { id: e.id, url: e.url, note: e.note || "", from: e.from || "", ts: e.ts || 0 });
items.sort((a, b) => b.ts - a.ts);
return { items };
},
- "bookmark.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
+ [N.ev("remove")]: (s, e) => ({ items: removeItem(s.items, e.id) }),
},
commands: {
// /save <url> [| note] — everything before the first "|" is the url,
@@ -78,7 +84,7 @@ export const app = defineApp({
const rawUrl = bar >= 0 ? args.slice(0, bar) : args;
const note = bar >= 0 ? args.slice(bar + 1).trim() : "";
const url = normalizeUrl(rawUrl);
- if (url) ctx.emit({ type: "bookmark.add", id: ctx.id(), url, note });
+ if (url) ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note });
},
},
},
@@ -94,7 +100,7 @@ export const app = defineApp({
label: "Save link",
glyph: "🔖",
run(ctx) {
- ctx.emit({ type: "bookmark.add", id: ctx.id(), url, note: "", from: msg.from || "" });
+ ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note: "", from: msg.from || "" });
},
};
},
@@ -172,7 +178,7 @@ export const app = defineApp({
ev.preventDefault();
const url = normalizeUrl(urlInput.value);
if (!url) return;
- ctx.emit({ type: "bookmark.add", id: ctx.id(), url, note: noteInput.value.trim() });
+ ctx.emit({ type: N.ev("add"), id: ctx.id(), url, note: noteInput.value.trim() });
urlInput.value = "";
noteInput.value = "";
};
@@ -223,7 +229,7 @@ export const app = defineApp({
rm.className = "bookmarks-remove";
rm.setAttribute("aria-label", "Remove bookmark");
rm.textContent = "×";
- rm.onclick = () => ctx.emit({ type: "bookmark.remove", id: item.id });
+ rm.onclick = () => ctx.emit({ type: N.ev("remove"), id: item.id });
li.append(main, rm);
list.appendChild(li);
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 2593d69..461ac70 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -22,7 +22,7 @@
//
// This is the DEFAULT chat experience for group/DM threads; a device can
// switch back per-device (⋮ menu → "Use original chat" — see chat.js).
-import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
+import { defineApp, namespace, keyedList, runCommand, runAppMention, messageActionsFor, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { openLightbox } from "../lightbox.js";
import { parseMessage, renderTree } from "../markdown.js";
@@ -71,6 +71,10 @@ const TYPING_INTERVAL_MS = 10000;
const TYPING_EXPIRY_S = 15;
const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches;
+// Event TYPES only: N.ev("msg") → "chat.msg", etc. (chat reuses the original
+// feed's shared CSS classes — .msg/.bubble/.feed/… — which stay literal.)
+const N = namespace("chat");
+
// Per-thread presence: name → epoch ms until which they count as typing.
// Ephemeral events never fold, so this lives outside reducer state — it's
// transient UI, allowed to differ between devices.
@@ -441,7 +445,7 @@ export const app = defineApp({
// ephemeral hook below). The no-op fold claims the type; if a hostile client
// stores one as a real message, it folds to nothing.
events: {
- "chat.typing": (s) => s,
+ [N.ev("typing")]: (s) => s,
},
// Host-only folds: the existing protocol, translated. Messages key on the
@@ -454,7 +458,7 @@ export const app = defineApp({
// reactions ride viewItems' soft rev and patch the row in place (see patchBubble),
// so reacting never recreates the bubble's photo or its playing YouTube embed.
hostEvents: {
- "chat.msg": (s, e, meta) => {
+ [N.ev("msg")]: (s, e, meta) => {
const text = typeof e.text === "string" ? e.text : "";
const atts = Array.isArray(e.attachments) ? e.attachments.filter((a) => a && a.id) : [];
const sid = (meta && meta.id) || 0;
@@ -496,7 +500,7 @@ export const app = defineApp({
msgs.sort((a, b) => a.sid - b.sid);
return { msgs };
},
- "chat.reaction": (s, e, meta, ctx) => {
+ [N.ev("reaction")]: (s, e, meta, ctx) => {
if (e.targetSid === undefined || e.emoji === undefined || e.uid === undefined || e.on === undefined) return s;
const me = ctx && ctx.me;
const msgs = s.msgs.map((m) => {
@@ -530,14 +534,14 @@ export const app = defineApp({
});
return { msgs };
},
- "chat.edit": (s, e) => ({
+ [N.ev("edit")]: (s, e) => ({
msgs: s.msgs.map((m) => {
if (m.sid !== e.msgId) return m;
const text = typeof e.text === "string" ? e.text : m.text;
return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 };
}),
}),
- "chat.delete": (s, e) => ({ msgs: s.msgs.filter((m) => m.sid !== e.msgId) }),
+ [N.ev("delete")]: (s, e) => ({ msgs: s.msgs.filter((m) => m.sid !== e.msgId) }),
},
// Typing: relay-only, never stored or folded. The server echoes your own beat
@@ -546,7 +550,7 @@ export const app = defineApp({
// messages folds into view. A uid-less legacy beat falls back to the screen
// name, mirroring the original feed's `payload.name === session.name` guard.
ephemeral(e, ctx) {
- if (e.type !== "chat.typing" || !e.name) return;
+ if (e.type !== N.ev("typing") || !e.name) return;
if (e.uid ? e.uid === ctx.me : e.name === ctx.meName) return;
let m = typing.get(ctx.threadId);
if (!m) typing.set(ctx.threadId, (m = new Map()));
@@ -3099,7 +3103,7 @@ function buildSkeleton(el, ctx) {
let beating = false; // a beat receivers are currently showing
const sendBeat = (expiry) => {
lastEmit = Date.now();
- ctx.emitEphemeral({ type: "chat.typing", expiry });
+ ctx.emitEphemeral({ type: N.ev("typing"), expiry });
};
const beatTyping = () => {
lastBeat = Date.now();
diff --git a/web/static/apps/connect.js b/web/static/apps/connect.js
index 07f8e58..614ee06 100644
--- a/web/static/apps/connect.js
+++ b/web/static/apps/connect.js
@@ -12,9 +12,13 @@
// Command: `/connect` opens this surface. (Listing domains is not a generic
// verb owned here — it belongs to the service you connected: the DNSimple app
// owns a context-local `/list` that only works inside its own thread.)
-import { defineApp, appByName, openAppThread } from "../apps.js";
+import { defineApp, namespace, appByName, openAppThread } from "../apps.js";
import { LOGO_PLUG } from "../logos.js";
+// One declaration of the app's prefix, reused everywhere: N.cls("card") → the
+// "connect-card" CSS class. Change "connect" here and every string below follows.
+const N = namespace("connect");
+
const unavailable = (ctx) => ctx.reply({ text: "Connecting services isn't available here yet." });
// When the registry can't be fetched, render what the surface always offered —
@@ -78,24 +82,24 @@ export const app = defineApp({
render(_state, el, ctx) {
el.textContent = "";
const wrap = document.createElement("div");
- wrap.className = "connect-app";
+ wrap.className = N.cls("app");
const h = document.createElement("h2");
- h.className = "connect-h";
+ h.className = N.cls("h");
h.textContent = "Integrations";
wrap.appendChild(h);
const sub = document.createElement("p");
- sub.className = "connect-sub";
+ sub.className = N.cls("sub");
sub.textContent = "Link an account so a bot can act for you — authorize on the provider's own site, or paste a scoped token. Either way the key is sealed in your browser; the server never sees it.";
wrap.appendChild(sub);
const list = document.createElement("div");
- list.className = "connect-list";
+ list.className = N.cls("list");
wrap.appendChild(list);
const note = document.createElement("div");
- note.className = "connect-note";
+ note.className = N.cls("note");
wrap.appendChild(note);
el.appendChild(wrap);
@@ -346,17 +350,17 @@ export const app = defineApp({
// floating dialog), per the house style: the prompt occupies layout.
rowNote.textContent = "";
const confirm = document.createElement("div");
- confirm.className = "connect-confirm";
+ confirm.className = N.cls("confirm");
const q = document.createElement("span");
q.textContent = `Disconnect ${svc.name || svc.id}? Your saved token will be removed from this server.`;
const yes = document.createElement("button");
yes.type = "button";
- yes.className = "connect-btn connect-danger";
+ yes.className = `${N.cls("btn")} ${N.cls("danger")}`;
yes.textContent = "Disconnect";
yes.onclick = () => host.disconnect(svc.id); // onChange repaints + notes on completion
const cancel = document.createElement("button");
cancel.type = "button";
- cancel.className = "connect-btn connect-cancel";
+ cancel.className = `${N.cls("btn")} ${N.cls("cancel")}`;
cancel.textContent = "Cancel";
cancel.onclick = () => (rowNote.textContent = "");
confirm.append(q, yes, cancel);
diff --git a/web/static/apps/dnsimple.js b/web/static/apps/dnsimple.js
index 1caa9cb..e058614 100644
--- a/web/static/apps/dnsimple.js
+++ b/web/static/apps/dnsimple.js
@@ -13,9 +13,15 @@
// It's a COMPANION app: implementing chatMessage(e) opts into the Chat | Interface
// split (see apps/README.md). Your domain list is folded state, so it persists
// across devices and survives reopen via the snapshot, just like any other app.
-import { defineApp } from "../apps.js";
+import { defineApp, namespace } from "../apps.js";
import { LOGO_DNSIMPLE } from "../logos.js";
+// One declaration of the app's event-type prefix, reused everywhere: N.ev("domains")
+// → the "dnsimple.domains" wire type. NOTE: the CSS classes intentionally use a
+// shorter `dns-` prefix (not matching the "dnsimple" name), so they're left as
+// literals — a single namespace can't produce both "dnsimple." and "dns-".
+const N = namespace("dnsimple");
+
// A tiny helper so both the Chat projection and the reducer agree on wording.
const summary = (domains) =>
domains.length ? `${domains.length} domain${domains.length === 1 ? "" : "s"}` : "no domains yet";
@@ -53,7 +59,7 @@ export const app = defineApp({
// One event: a fetched domain list. The latest refresh wins (replace, not
// append). Validate defensively — a malformed event folds to a no-op.
events: {
- "dnsimple.domains": (s, e) =>
+ [N.ev("domains")]: (s, e) =>
Array.isArray(e.domains) ? { domains: normalizeDomains(e.domains), fetchedAt: e.ts || s.fetchedAt } : s,
},
@@ -303,7 +309,7 @@ export const app = defineApp({
// Emit the list as an event: it folds into state (so the Interface and
// Chat both update and it syncs across your devices) and triggers a
// re-render from the framework with the fresh domains.
- ctx.emit({ type: "dnsimple.domains", domains: res.domains || [] });
+ ctx.emit({ type: N.ev("domains"), domains: res.domains || [] });
} else {
// Loud, in the main area — a failed load must never look like "no domains".
const msg = (res && res.error) || "Couldn't reach DNSimple just now.";
diff --git a/web/static/apps/poll.js b/web/static/apps/poll.js
index 4fe1cc8..3b4f94a 100644
--- a/web/static/apps/poll.js
+++ b/web/static/apps/poll.js
@@ -7,9 +7,16 @@
//
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
-import { defineApp, addItem, keyedList } from "../apps.js";
+import { defineApp, namespace, addItem, keyedList } from "../apps.js";
import { ICON_CHART_COLUMN } from "../icons.js";
+// One declaration of the app's prefix, reused everywhere: N.ev("vote") → the
+// "poll.vote" wire type (used as the `events` keys and in every emit/compare),
+// N.cls("bar") → the "poll-bar" CSS class. Change "poll" here and every string
+// below follows — the prefix can't drift. (Inside render/commands the same helper
+// is on ctx as ctx.ev / ctx.cls.)
+const N = namespace("poll");
+
// pollSummary turns a finished poll into the plain-text results message that
// gets posted back into the chat — options with their counts, then the verdict
// (winner, tie, or no votes). Pure and exported for tests.
@@ -54,18 +61,18 @@ export const app = defineApp({
placeNote: "A poll needs an audience — it lives in a group chat.",
initialState: () => ({ polls: [] }),
events: {
- "poll.create": (s, e) => {
+ [N.ev("create")]: (s, e) => {
const options = Array.isArray(e.options) ? e.options.filter((o) => typeof o === "string" && o.trim()).map((o) => o.trim()) : [];
if (!e.id || typeof e.q !== "string" || !e.q.trim() || options.length < 2) return s;
return { polls: addItem(s.polls, { id: e.id, q: e.q.trim(), options, votes: {}, by: e.uid || "" }) };
},
- "poll.vote": (s, e) => {
+ [N.ev("vote")]: (s, e) => {
const poll = s.polls.find((p) => p.id === e.pollId);
const choice = e.choice;
if (!poll || !e.uid || !Number.isInteger(choice) || choice < 0 || choice >= poll.options.length) return s;
return { polls: s.polls.map((p) => (p.id === e.pollId ? { ...p, votes: { ...p.votes, [e.uid]: choice } } : p)) };
},
- "poll.close": (s, e) => ({
+ [N.ev("close")]: (s, e) => ({
// Only the creator may close (a legacy poll with no `by` accepts anyone).
polls: s.polls.filter((p) => !(p.id === e.pollId && (!p.by || p.by === e.uid))),
}),
@@ -78,14 +85,14 @@ export const app = defineApp({
const hadSeparators = args.includes("|");
const options = hadSeparators ? parts.filter(Boolean) : ["Yes", "No"];
if (!q || options.length < 2) return;
- ctx.emit({ type: "poll.create", id: ctx.id(), q, options });
+ ctx.emit({ type: N.ev("create"), id: ctx.id(), q, options });
},
},
// Sidebar presence: a new poll is worth surfacing — preview the question and
// light the unread dot (votes stay silent; the tally updates live anyway).
- preview: (e) => (e.type === "poll.create" && e.q ? `📊 ${e.q}` : null),
- unread: (e) => e.type === "poll.create",
+ preview: (e) => (e.type === N.ev("create") && e.q ? `📊 ${e.q}` : null),
+ unread: (e) => e.type === N.ev("create"),
// This app's CSS travels with it — the framework injects it once. It reads the
// theme's CSS variables (var(--accent) etc.) so it follows the chosen theme.
@@ -175,33 +182,33 @@ export const app = defineApp({
// board.js's handlers read wrap._ctx (#app-surface is reused across threads).
function buildCard(poll, ctx) {
const card = document.createElement("div");
- card.className = "poll-card";
+ card.className = N.cls("card");
card._poll = poll;
card._ctx = ctx;
const q = document.createElement("div");
- q.className = "poll-q";
+ q.className = N.cls("q");
q.textContent = `📊 ${poll.q}`; // textContent — never innerHTML for user content
card.appendChild(q);
const opts = document.createElement("div");
- opts.className = "poll-opts";
+ opts.className = N.cls("opts");
// One button per option, built once; updateCard fills in fill/tally/mine.
card._optNodes = poll.options.map((label, i) => {
const btn = document.createElement("button");
btn.type = "button";
- btn.className = "poll-opt";
- btn.onclick = () => card._ctx.emit({ type: "poll.vote", pollId: poll.id, choice: i });
+ btn.className = N.cls("opt");
+ btn.onclick = () => card._ctx.emit({ type: N.ev("vote"), pollId: poll.id, choice: i });
const fill = document.createElement("span");
- fill.className = "poll-fill";
+ fill.className = N.cls("fill");
const text = document.createElement("span");
- text.className = "poll-label";
+ text.className = N.cls("label");
text.textContent = label; // static — an option's label never changes
const tally = document.createElement("span");
- tally.className = "poll-tally";
+ tally.className = N.cls("tally");
btn.append(fill, text, tally);
opts.appendChild(btn);
@@ -214,14 +221,14 @@ function buildCard(poll, ctx) {
// results back into the chat as a NORMAL message, so classic and lab feeds
// alike keep a durable record of the outcome.
const foot = document.createElement("div");
- foot.className = "poll-foot";
+ foot.className = N.cls("foot");
card._totalLine = document.createElement("div");
- card._totalLine.className = "poll-total";
+ card._totalLine.className = N.cls("total");
foot.appendChild(card._totalLine);
if (!poll.by || poll.by === ctx.me) {
const close = document.createElement("button");
close.type = "button";
- close.className = "poll-close";
+ close.className = N.cls("close");
close.textContent = "Close poll";
// Two-step inline confirm instead of confirm(): embedded webviews (the
// Mac shell) have no confirm panel — the dialog silently answers false
@@ -240,7 +247,7 @@ function buildCard(poll, ctx) {
}, 4000);
return;
}
- card._ctx.emit({ type: "poll.close", pollId: card._poll.id });
+ card._ctx.emit({ type: N.ev("close"), pollId: card._poll.id });
card._ctx.emit({ text: pollSummary(card._poll) }); // a plain chat message — the durable record
};
foot.appendChild(close);
diff --git a/web/static/apps/remind.js b/web/static/apps/remind.js
index be6ffc5..501e3b1 100644
--- a/web/static/apps/remind.js
+++ b/web/static/apps/remind.js
@@ -27,11 +27,18 @@
//
// Relative import (not "/static/apps.js") so the parser + reducer are exercisable
// in Node tests while still resolving to /static/apps.js in the browser.
-import { defineApp } from "../apps.js";
+import { defineApp, namespace } from "../apps.js";
import { ICON_ALARM_CLOCK } from "../icons.js";
import { schemaEvents, schemaInitialState, schemaRender, schemaStyles } from "../schema-render.js";
import schema from "./schemas/remind.json" with { type: "json" };
+// The schema (schemas/remind.json) is the source of truth for the `events` map
+// keys, so there's no drift there — but the hand-written glue below (commands,
+// chatMessage/preview/unread) still compares against the wire type by hand.
+// N.ev("add") → "remind.add" keeps THOSE comparisons locked to the schema's own
+// name instead of a second hardcoded "remind" prefix.
+const N = namespace(schema.name);
+
// ---- time parsing (pure, unit-tested) -----------------------------------
// Milliseconds for a relative unit word ("m"/"min"/"minutes" → 60000, etc).
@@ -145,10 +152,10 @@ function absTime(when) {
async function addReminder(ctx, text, when) {
const id = ctx.id();
const sid = await ctx.schedule(
- { type: "remind.fire", id, notice: { title: "⏰ Reminder", body: text } },
+ { type: N.ev("fire"), id, notice: { title: "⏰ Reminder", body: text } },
when,
);
- ctx.emit({ type: "remind.add", id, text, when, sid: sid || "" });
+ ctx.emit({ type: N.ev("add"), id, text, when, sid: sid || "" });
}
// A reminder is { id, text, when, ts, sid, fired, firedAt } — see
@@ -210,11 +217,11 @@ export const app = defineApp({
// Firing is the bot messaging YOU (a bubble); setting one is an action you
// took. In a GROUP the bubble speaks to everyone — the reminder may have
// been set by another member, so "you asked me" would be wrong there.
- if (e.type === "remind.fire") {
+ if (e.type === N.ev("fire")) {
const body = (e.notice && e.notice.body) || "";
return opts && opts.group ? `Reminder: “${body}”` : `You asked me to remind you: “${body}”`;
}
- if (e.type === "remind.add") {
+ if (e.type === N.ev("add")) {
const at = absTime(e.when);
return { text: `set a reminder: “${e.text}”${at ? " — " + at : ""}`, action: true };
}
@@ -223,12 +230,12 @@ export const app = defineApp({
preview(e) {
// The reminder text rides in the scheduled event's encrypted notice.
- if (e.type === "remind.fire") return "⏰ " + ((e.notice && e.notice.body) || "Reminder fired");
+ if (e.type === N.ev("fire")) return "⏰ " + ((e.notice && e.notice.body) || "Reminder fired");
return null;
},
unread(e) {
- return e.type === "remind.fire";
+ return e.type === N.ev("fire");
},
styles: schemaStyles,
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js
index 93d992d..b37d96a 100644
--- a/web/static/apps/setup.js
+++ b/web/static/apps/setup.js
@@ -10,12 +10,15 @@
// the flow may name) and the io wiring the engine to eleven.js's feel (pacing,
// persistence) and shell.js's setup bridge (setSetupBridge). Like /tutorial it
// must work with no AI and no bot at all (a fresh instance has neither).
-import { defineApp, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js";
+import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js";
import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js";
import { runFlow } from "../flow.js";
import SETUP_FLOW from "../setup.flow.js";
import { startTour } from "../tutorial.js";
+// One declaration of the app's prefix — N.ev("step") → the "setup.step" wire type.
+const N = namespace("setup");
+
export const app = defineApp({
name: "setup",
title: "Eleven",
@@ -35,15 +38,15 @@ export const app = defineApp({
events: {
// A completed step. `label` is the human line the chat view shows; `value`
// (optional) remembers the answer, for steps that resume on it.
- "setup.step": (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
+ [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
// Eleven's own lines persist too (via makeSay), so the conversation is
// real history after a reload, not just the ✓ action lines.
- ...sayEvents("setup.say"),
+ ...sayEvents(N.ev("say")),
},
chatMessage(e) {
- const line = sayBubble(e, "setup.say");
+ const line = sayBubble(e, N.ev("say"));
if (line) return line;
- if (e.type !== "setup.step" || !e.label) return null;
+ if (e.type !== N.ev("step") || !e.label) return null;
return { text: e.label, action: true };
},
});
@@ -53,7 +56,7 @@ export const app = defineApp({
// One live run per thread (a reload starts a fresh one; the engine's step
// guards and say-dedupe handle everything within).
const running = new Set();
-const say = makeSay("setup", "setup.say");
+const say = makeSay("setup", N.ev("say"));
// The bridge-verb table: the CLOSED set of side effects the setup flow may
// name (docs/conversation-scripts.md § interpreter contract). A flow is data —
@@ -123,7 +126,7 @@ export function startSetup(env) {
ask: awaitAnswer,
cancelAnswer,
verb,
- record: (step, label, value) => emitAppEvent(env.threadId, { type: "setup.step", step, label, value: value || "" }),
+ record: (step, label, value) => emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }),
done: (step) => {
const st = appStateFor(env.threadId, "setup");
return !!(st && st.steps && st.steps[step] && st.steps[step].done);
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index ee7bef7..d152004 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -12,11 +12,18 @@
//
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
-import { defineApp } from "../apps.js";
+import { defineApp, namespace } from "../apps.js";
import { ICON_SQUARE_CHECK } from "../icons.js";
import { schemaEvents, schemaInitialState, schemaRender, schemaStyles } from "../schema-render.js";
import schema from "./schemas/todo.json" with { type: "json" };
+// The schema (schemas/todo.json) is the source of truth for the `events` map
+// keys, so there's no drift there — but the hand-written glue below (commands,
+// chatMessage/preview/unread) still compares against the wire type by hand.
+// N.ev("add") → "todo.add" keeps THOSE comparisons locked to the schema's own
+// name instead of a second hardcoded "todo" prefix.
+const N = namespace(schema.name);
+
// A to-do is { id, text, done, doneAt, by } — see schemas/todo.json for the
// authoritative shape, folds (idempotent add, grace-window partition) and views.
export const app = defineApp({
@@ -60,7 +67,7 @@ export const app = defineApp({
// done/reopen/remove/move happen in the Interface view and carry no text, so
// they don't clutter the chat. In a group the host prefixes the actor's name.
chatMessage(e) {
- if (e.type === "todo.add" && e.text) return { text: `added “${e.text}”`, action: true };
+ if (e.type === N.ev("add") && e.text) return { text: `added “${e.text}”`, action: true };
return null;
},
@@ -69,10 +76,10 @@ export const app = defineApp({
// thread. Your own adds never light the dot (the host's ownMessage gate), and
// ticks/removals/moves stay quiet everywhere.
preview(e) {
- if (e.type === "todo.add" && e.text) return `✓ ${e.name || "Someone"} added “${e.text}”`;
+ if (e.type === N.ev("add") && e.text) return `✓ ${e.name || "Someone"} added “${e.text}”`;
return null;
},
- unread: (e) => e.type === "todo.add",
+ unread: (e) => e.type === N.ev("add"),
// The empty-state intro shown the first time you open this bot's chat.
chatIntro:
@@ -88,7 +95,7 @@ export const app = defineApp({
function addTodo(args, ctx, verb) {
const text = args.trim();
if (!text) return ctx.reply({ text: `Usage: ${verb} <task> — e.g. ${verb} buy milk` });
- ctx.emit({ type: "todo.add", id: ctx.id(), text });
+ ctx.emit({ type: N.ev("add"), id: ctx.id(), text });
// Name the destination list (ctx.place): the same /todo can land in your own
// list, this chat's, or the space's — the reply saying WHICH is the places
// grammar's disclosure. (The framework also stamps a place footer on routed
diff --git a/web/static/apps/welcome.js b/web/static/apps/welcome.js
index a3828ce..f39d00b 100644
--- a/web/static/apps/welcome.js
+++ b/web/static/apps/welcome.js
@@ -10,12 +10,15 @@
// + the host binding: the bridge-verb table and the io wiring the engine to
// eleven.js's feel and shell.js's setup bridge. Like setup it must work with
// no AI and no sidecar (the sidecar's welcome DM stood down in its favour).
-import { defineApp, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo } from "../apps.js";
+import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo } from "../apps.js";
import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js";
import { runFlow } from "../flow.js";
import WELCOME_FLOW from "../welcome.flow.js";
import { startTour } from "../tutorial.js";
+// One declaration of the app's prefix — N.ev("step") → the "welcome.step" wire type.
+const N = namespace("welcome");
+
export const app = defineApp({
name: "welcome",
title: "Eleven",
@@ -31,13 +34,13 @@ export const app = defineApp({
places: [], // host-mounted conversation
initialState: () => ({ steps: {}, said: {} }),
events: {
- "welcome.step": (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
- ...sayEvents("welcome.say"),
+ [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
+ ...sayEvents(N.ev("say")),
},
chatMessage(e) {
- const line = sayBubble(e, "welcome.say");
+ const line = sayBubble(e, N.ev("say"));
if (line) return line;
- if (e.type !== "welcome.step" || !e.label) return null;
+ if (e.type !== N.ev("step") || !e.label) return null;
return { text: e.label, action: true };
},
});
@@ -45,7 +48,7 @@ export const app = defineApp({
// ---- the host binding -------------------------------------------------------
const running = new Set();
-const say = makeSay("welcome", "welcome.say");
+const say = makeSay("welcome", N.ev("say"));
const stepState = (threadId, step) => {
const st = appStateFor(threadId, "welcome");
@@ -126,7 +129,7 @@ export function startWelcome(env) {
ask: awaitAnswer,
cancelAnswer,
verb: makeVerb(env),
- record: (step, label, value) => emitAppEvent(env.threadId, { type: "welcome.step", step, label, value: value || "" }),
+ record: (step, label, value) => emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }),
done: (step) => {
const s = stepState(env.threadId, step);
return !!(s && s.done);
diff --git a/web/static/sandbox-frame.js b/web/static/sandbox-frame.js
index 08fc742..529490c 100644
--- a/web/static/sandbox-frame.js
+++ b/web/static/sandbox-frame.js
@@ -113,6 +113,17 @@ export function partitionByGrace(items, doneAtOf, now, graceMs = DONE_GRACE_MS)
return { active, grace, archived };
}
+// namespace(ns) — verbatim copy of apps.js's helper (pinned by test/sandbox.test.mjs):
+// N.ev("add") → "todo.add" (event type), N.cls("item") → "todo-item" (CSS class).
+export function namespace(ns) {
+ const base = String(ns || "");
+ return {
+ ns: base,
+ ev: (name) => (name != null && String(name).includes(".") ? String(name) : base + "." + name),
+ cls: (name) => base + "-" + name,
+ };
+}
+
export function keyedList(el, items, opts) {
const { key, render, rev = () => "", srev = () => "", update } = opts;
const existing = new Map();
@@ -154,6 +165,7 @@ export function keyedList(el, items, opts) {
// the sandbox yet; the runtime must stay a single self-contained script.)
export const ALLOWED_IMPORTS = [
"defineApp",
+ "namespace",
"addItem",
"updateItem",
"removeItem",
@@ -280,6 +292,7 @@ export function makeSandboxReducer(events, initialState) {
export function evalMemberApp(source, expectedName) {
let captured = null;
const sdk = {
+ namespace,
addItem,
updateItem,
removeItem,
@@ -290,6 +303,9 @@ export function evalMemberApp(source, expectedName) {
if (captured) throw new Error("defineApp called more than once");
if (!spec || typeof spec !== "object") throw new Error("defineApp needs a spec object");
captured = { ...spec };
+ // Mirror apps.js: namespace defaults to name, and app.ns is the resolver.
+ captured.namespace = captured.namespace || captured.name;
+ captured.ns = namespace(captured.namespace);
captured.types = Object.keys(captured.events || {});
captured.reduce = makeSandboxReducer(captured.events || {}, captured.initialState);
return captured;
@@ -446,6 +462,11 @@ function bootFrame() {
me: null,
meName: null,
id: uuid,
+ // Namespace resolvers bound to this app (see apps.js makeCtx). `spec` is
+ // assigned during init, so read it lazily; before then a bare name is
+ // returned so an early call never throws.
+ ev: (name) => (spec && spec.ns ? spec.ns.ev(name) : String(name)),
+ cls: (name) => (spec && spec.ns ? spec.ns.cls(name) : String(name)),
getState: () => state,
emit(event) {
send({ type: "emit", event });