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>
20 files changed,
+506
−185
docs/building-apps.md+58 −17docs/building-member-apps.md+53 −24internal/bot/appbuild.go+1 −1test/apps.test.mjs+30 −0test/sandbox.test.mjs+32 −0web/static/apps.js+40 −0web/static/apps/README.md+75 −13web/static/apps/admin.js+28 −23web/static/apps/appstudio.js+11 −4web/static/apps/board.js+38 −31web/static/apps/bookmarks.js+13 −7web/static/apps/chat.js+12 −8web/static/apps/connect.js+13 −9web/static/apps/dnsimple.js+9 −3web/static/apps/poll.js+26 −19web/static/apps/remind.js+14 −7web/static/apps/setup.js+10 −7web/static/apps/todo.js+12 −5web/static/apps/welcome.js+10 −7web/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 }); |