@mulmoclaude/core 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -122,27 +122,27 @@ when the user makes a request.
122
122
  Top-level shape (validated on discovery; a malformed schema is logged and
123
123
  skipped, never crashes the host):
124
124
 
125
- | Key | Meaning |
126
- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
- | `title` | Human name shown in the sidebar / header. Required. |
128
- | `icon` | A **Material Symbols** name (`receipt_long`, `people`, `schedule`, `menu_book`). Required. |
129
- | `dataPath` | Workspace-relative records folder, e.g. `data/recipes/items`. Must stay under the workspace. Required. |
130
- | `primaryKey` | The field name whose value is the filename. That field MUST set `primary: true`. The value must be a valid record id (see the **Records** section's id-charset rule). Required. |
131
- | `singleton` | Optional. When set, at most one record exists, pinned to this exact id (e.g. `me`). Host pre-fills + locks the create form and hides Add once it exists. |
132
- | `fields` | Ordered map of field-name → field spec. **Insertion order = column order** in the table. Required. |
133
- | `actions` | Optional array of per-record buttons (see below). |
134
- | `completionField` | Optional. Name of the field whose value marks an item as "done" — when set, item-create fires a bell notification that clears once the field reaches one of `completionDoneValues`. Must name a real field in `fields`. Paired with `completionDoneValues` (both set, or both omitted). |
135
- | `completionDoneValues` | Optional. Non-empty array of values that count as "done" for `completionField` (e.g. `["Done"]`, `["paid", "void"]`). Compared as strings. |
136
- | `notifyWhen` | Optional. A `when` predicate (`{ "field": "...", "in": [...] }`) that **gates** the completion bell: fire it only for records matching the predicate (e.g. `{ "field": "priority", "in": ["high", "urgent"] }`). Requires `completionField`; `field` must name a real field. Absent ⇒ notify for every open record. |
137
- | `displayField` | Optional. Name of a field whose value is shown as the human-readable label in the completion notification's title (e.g. `Contacts: Jane Doe` instead of the opaque primaryKey). Must name a real field in `fields`. Falls back to the primaryKey value when unset or when the record's value is empty. |
138
- | `triggerField` | Optional. Name of a `date` field that **delays** the completion bell until that date arrives (instead of firing on create). Requires `completionField` / `completionDoneValues` (the bell still clears via the done value). Must name a real `date` field. See "Time-gated bells" below. |
139
- | `triggerLeadDays` | Optional. Non-negative integer: fire the bell this many days **before** `triggerField` (e.g. `10` = "remind me 10 days early"). Requires `triggerField`. Default `0` (fire on the trigger date). |
140
- | `spawn` | Optional. Host-driven **recurrence**: when a record reaches a configured value (e.g. `status: paid`), the host auto-creates the next record with a forward-advanced `triggerField` date. Requires `triggerField`. See "Recurring obligations" below. |
141
- | `calendarField` | Optional. Name of a `date` (or `datetime`) field that anchors the **calendar view** (a month grid; each record lands on its date cell). When unset, the table↔calendar toggle still appears if the schema has any `date`/`datetime` field — the first one is used, switchable in-view. Set this to pin a specific anchor. Must name a real `date`/`datetime` field. See "Calendar view" below. |
142
- | `calendarEndField` | Optional. A second `date`/`datetime` field marking the END of a multi-day span on the calendar (the record renders across `calendarField` → this date). Requires `calendarField`. Must name a real `date`/`datetime` field. |
143
- | `calendarTimeField` | Optional. Name of a string field holding a free-form time or time-range (`"14:00-17:00"`, `"17:00-"`, `"16:30"`) used to place records on the calendar's **day (time-allocation) view**. Consulted only when the date fields are date-only — a `datetime` anchor/end pair carries its own clock and takes precedence. Requires `calendarField`. See "Calendar view" below. |
144
- | `kanbanField` | Optional. Name of an `enum` field that groups records into columns on the **Kanban board** (one column per declared value). When unset, the Kanban toggle still appears if the schema has any `enum` field — the first one is used, switchable in-view. Set this to pin a specific group field. Must name a real `enum` field. See "Kanban view" below. |
145
- | `views` | Optional. Custom (LLM-authored) HTML views: `[{ id, label, icon?, file, capabilities? }]`. Each renders an HTML file under `views/*.html` in a sandboxed iframe over the records, for layouts the built-ins don't cover (year/quarter overview, Gantt, report). `capabilities` is `["read"]` (default) or `["read","write"]`. See "Custom views" below and **`config/helps/custom-view.md`** for the full authoring contract. |
125
+ | Key | Meaning |
126
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `title` | Human name shown in the sidebar / header. Required. |
128
+ | `icon` | A **Material Symbols** name (`receipt_long`, `people`, `schedule`, `menu_book`). Required. |
129
+ | `dataPath` | Workspace-relative records folder, e.g. `data/recipes/items`. Must stay under the workspace. Required. |
130
+ | `primaryKey` | The field name whose value is the filename. That field MUST set `primary: true`. The value must be a valid record id (see the **Records** section's id-charset rule). Required. |
131
+ | `singleton` | Optional. When set, at most one record exists, pinned to this exact id (e.g. `me`). Host pre-fills + locks the create form and hides Add once it exists. |
132
+ | `fields` | Ordered map of field-name → field spec. **Insertion order = column order** in the table. Required. |
133
+ | `actions` | Optional array of per-record buttons (see below). |
134
+ | `completionField` | Optional. Name of the field whose value marks an item as "done" — when set, item-create fires a bell notification that clears once the field reaches one of `completionDoneValues`. Must name a real field in `fields`. Paired with `completionDoneValues` (both set, or both omitted). |
135
+ | `completionDoneValues` | Optional. Non-empty array of values that count as "done" for `completionField` (e.g. `["Done"]`, `["paid", "void"]`). Compared as strings. |
136
+ | `notifyWhen` | Optional. A `when` predicate (`{ "field": "...", "in": [...] }`) that **gates** the completion bell: fire it only for records matching the predicate (e.g. `{ "field": "priority", "in": ["high", "urgent"] }`). Requires `completionField`; `field` must name a real field. Absent ⇒ notify for every open record. |
137
+ | `displayField` | Optional. Name of a field whose value is shown as the human-readable label in the completion notification's title (e.g. `Contacts: Jane Doe` instead of the opaque primaryKey). Must name a real field in `fields`. Falls back to the primaryKey value when unset or when the record's value is empty. |
138
+ | `triggerField` | Optional. Name of a `date` field that **delays** the completion bell until that date arrives (instead of firing on create). Requires `completionField` / `completionDoneValues` (the bell still clears via the done value). Must name a real `date` field. See "Time-gated bells" below. |
139
+ | `triggerLeadDays` | Optional. Non-negative integer: fire the bell this many days **before** `triggerField` (e.g. `10` = "remind me 10 days early"). Requires `triggerField`. Default `0` (fire on the trigger date). |
140
+ | `spawn` | Optional. Host-driven **recurrence**: when a record reaches a configured value (e.g. `status: paid`), the host auto-creates the next record with a forward-advanced `triggerField` date. Requires `triggerField`. See "Recurring obligations" below. |
141
+ | `calendarField` | Optional. Name of a `date` (or `datetime`) field that anchors the **calendar view** (a month grid; each record lands on its date cell). When unset, the table↔calendar toggle still appears if the schema has any `date`/`datetime` field — the first one is used, switchable in-view. Set this to pin a specific anchor. Must name a real `date`/`datetime` field. See "Calendar view" below. |
142
+ | `calendarEndField` | Optional. A second `date`/`datetime` field marking the END of a multi-day span on the calendar (the record renders across `calendarField` → this date). Requires `calendarField`. Must name a real `date`/`datetime` field. |
143
+ | `calendarTimeField` | Optional. Name of a string field holding a free-form time or time-range (`"14:00-17:00"`, `"17:00-"`, `"16:30"`) used to place records on the calendar's **day (time-allocation) view**. Consulted only when the date fields are date-only — a `datetime` anchor/end pair carries its own clock and takes precedence. Requires `calendarField`. See "Calendar view" below. |
144
+ | `kanbanField` | Optional. Name of an `enum` field that groups records into columns on the **Kanban board** (one column per declared value). When unset, the Kanban toggle still appears if the schema has any `enum` field — the first one is used, switchable in-view. Set this to pin a specific group field. Must name a real `enum` field. See "Kanban view" below. |
145
+ | `views` | Optional. Custom (LLM-authored) HTML views: `[{ id, label, icon?, file, capabilities?, target? }]`. Each renders an HTML file under `views/*.html` in a sandboxed iframe over the records, for layouts the built-ins don't cover (year/quarter overview, Gantt, report). `capabilities` is `["read"]` (default) or `["read","write"]`. `target: "mobile"` makes it a **remote view** for the phone app (different runtime contract — **`config/helps/custom-view-remote.md`**). See "Custom views" below and **`config/helps/custom-view.md`** for the desktop authoring contract. |
146
146
 
147
147
  ### Field types
148
148
 
@@ -744,7 +744,10 @@ in the collection's view-mode selector.
744
744
  The host holds **zero** view-specific code — the view is data, like the rest of
745
745
  the collection. Full authoring contract (the `window.__MC_VIEW` shape, the
746
746
  read/write API, the sandbox rules, and two complete sample views):
747
- **`config/helps/custom-view.md`**. Read it before authoring a view.
747
+ **`config/helps/custom-view.md`**. Read it before authoring a view. For a view
748
+ that runs on the **phone remote app** (`target: "mobile"` in the `views[]`
749
+ entry) the contract is different and incompatible — read
750
+ **`config/helps/custom-view-remote.md`** instead.
748
751
 
749
752
  ### Worked example: a Todo list
750
753
 
@@ -861,6 +864,6 @@ recipe when the user wants any of them, and copy the schema verbatim:
861
864
  - **`profile`** — one record per issuer identity (primary id `me`); each invoice
862
865
  picks one.
863
866
  - **`invoice`** — the full toolkit in one schema: a `ref` issuer (`issuerId →
864
- profile`) embedded via an `idField` `embed`, a `ref` client (`clients`), a
867
+ profile`) embedded via an `idField` `embed`, a `ref` client (`clients`), a
865
868
  `table` of line items, three `derived` money fields, an `enum` status, and four
866
869
  `actions` (PDF always-on; sale / payment / void gated by `status` via `when`).
@@ -0,0 +1,429 @@
1
+ # Remote custom views (LLM-authored HTML for the phone)
2
+
3
+ A **remote custom view** is an HTML file you write that renders a collection's
4
+ records **on the user's phone**, inside the mobile remote app. Same idea as a
5
+ desktop custom view (`config/helps/custom-view.md`) — the user asks in plain
6
+ language, you author one HTML file and register it — but the runtime contract
7
+ is **different and incompatible**: the phone cannot reach this machine, so
8
+ there is **no token, no dataUrl, and no `fetch` of any kind**. Records arrive
9
+ through an async bridge instead. Never mix the two contracts in one file.
10
+
11
+ Read `config/helps/collection-skills.md` first for the collection/schema DSL.
12
+ This file is only about the **remote view** layer.
13
+
14
+ ## Where the files go
15
+
16
+ Same place as desktop views — the HTML lives under `views/` and must end in
17
+ `.html`. Register it in the collection's `schema.json` with
18
+ **`target: "mobile"`** (that's the whole difference in registration):
19
+
20
+ ```jsonc
21
+ {
22
+ "views": [{ "id": "phone", "label": "Phone", "target": "mobile", "file": "views/phone.html" }],
23
+ }
24
+ ```
25
+
26
+ - **`target: "mobile"`** — required; without it the view is treated as a
27
+ desktop view and is never served to the phone.
28
+ - `id` / `label` / `icon` / `i18n` — as for desktop views. The selector icon
29
+ defaults to `smartphone`.
30
+ - **Read-only by default.** A view with no `editableFields` and no `allowDelete`
31
+ cannot mutate anything — its `updateItem` / `deleteItem` reject. Declare a
32
+ write surface only when the user asks the view to _change_ data (toggle a
33
+ checkbox, delete a row); see **Writing records** below. For anything more
34
+ open-ended (compose a message, kick off a task) use `startChat`.
35
+
36
+ Feed collections register theirs in `feeds/<slug>/schema.json` with the HTML
37
+ at `feeds/<slug>/views/<name>.html`, like desktop views.
38
+
39
+ ## Previewing without a phone
40
+
41
+ After you Write + register the view, its button appears in the collection's
42
+ view selector **on the desktop** and renders inside a **phone-sized frame
43
+ (390×844)** — the exact same wrapped document the phone receives, with the same
44
+ sandbox rules and the same data bridge. A caption under the frame shows the
45
+ file's size against its **900 KB budget** (the view travels to the phone inside
46
+ a size-capped message — stay well under it; a normal view is tens of KB).
47
+ Iterate against this preview; if it works there, it works on the phone.
48
+
49
+ ## The runtime contract — `window.__MC_VIEW`
50
+
51
+ The host injects a bootstrap into your page **before any of your scripts run**:
52
+
53
+ ```js
54
+ window.__MC_VIEW = {
55
+ slug: "annual-plan", // this collection
56
+ locale: "en", // active app locale ("" when no translations)
57
+ target: "mobile",
58
+ protocol: 2,
59
+ writable: false, // true iff the view declared editableFields / allowDelete
60
+ getItems: (opts) => Promise, // the ONLY way to read records — see below
61
+ updateItem: (id, patch) => Promise, // patch declared fields (see Writing records)
62
+ deleteItem: (id) => Promise, // remove a record (requires allowDelete)
63
+ startChat: (prompt, role) => void, // draft a new chat for the user
64
+ t: (key, named) => string, // vue-i18n-style dict lookup (same as desktop)
65
+ };
66
+ ```
67
+
68
+ What is deliberately **absent** compared to the desktop contract: `token`,
69
+ `dataUrl` (nothing to fetch), `onChange` (no live refresh yet — re-call
70
+ `getItems` after a mutate resolves), and `openItem` (no host record panel on
71
+ the phone yet).
72
+
73
+ ### Reading records — `getItems` (paginated, always)
74
+
75
+ ```js
76
+ const page = await window.__MC_VIEW.getItems({
77
+ offset: 0,
78
+ limit: 50, // clamped to [1, 200]
79
+ fields: ["title", "start", "status"], // project — list ONLY what you render
80
+ });
81
+ // page = { items: [...], total: 123, offset: 0, limit: 50 }
82
+ ```
83
+
84
+ - **Pagination is mandatory.** Each page travels to the phone through a
85
+ size-capped channel; you can never assume one call returns everything.
86
+ Render the first page, then offer a **"Load more"** affordance while
87
+ `items.length < total` (see the example).
88
+ - **Always pass `fields`.** The projection keeps pages small (the primary key
89
+ is always included). List the columns your view actually renders.
90
+ - The promise **rejects** on failure or after a 30 s timeout — catch it and
91
+ show the message; don't fail silently.
92
+ - **`derived` formulas that read only the record's own fields come back
93
+ resolved** (e.g. `won * 3 + drawn`). Formulas that dereference a `ref`
94
+ field, and `embed` fields, are NOT resolved on the phone — don't rely on
95
+ them; compute from base fields or omit.
96
+
97
+ ### Writing records — `updateItem` / `deleteItem`
98
+
99
+ A remote view may **change** data too — flip a todo's `done`, edit a status,
100
+ delete a row — but only within a surface the view **declares** and the host
101
+ **enforces**. Nothing is writable by default.
102
+
103
+ **1. Declare the surface** in the `views[]` entry:
104
+
105
+ ```jsonc
106
+ {
107
+ "id": "phone",
108
+ "label": "Todos",
109
+ "target": "mobile",
110
+ "file": "views/phone.html",
111
+ "editableFields": ["done"], // ONLY these fields may be patched
112
+ "allowDelete": true, // omit / false ⇒ deleteItem is refused
113
+ }
114
+ ```
115
+
116
+ **2. Call the methods** (both resolve/reject like `getItems`):
117
+
118
+ ```js
119
+ const { item } = await window.__MC_VIEW.updateItem(id, { done: true });
120
+ // patch is a partial record; the host merges it onto the stored record and
121
+ // returns the merged { item }.
122
+
123
+ const { id: removed } = await window.__MC_VIEW.deleteItem(id);
124
+ ```
125
+
126
+ - **The host is the authority, not the view.** Every mutate is re-checked
127
+ server-side: a patch key not in `editableFields` (or the primary key) is
128
+ **refused**; `deleteItem` without `allowDelete` is **refused**. The promise
129
+ rejects with the reason — surface it. Declaring the surface honestly is how
130
+ you keep the blast radius small.
131
+ - **No `writable` declaration ⇒ the methods reject** (`"this view is
132
+ read-only"`). Check `window.__MC_VIEW.writable` before showing edit affordances
133
+ if you want to degrade gracefully.
134
+ - **`editableFields` never includes the primary key** — a record's id is fixed.
135
+ - **Re-render after a mutate resolves.** There is no live `onChange` yet: either
136
+ optimistically update your local copy from the returned `item`, or re-call
137
+ `getItems` to refetch. The preview's real write also refreshes the desktop
138
+ collection, so you see the true result while iterating.
139
+ - **Create is not available** in this phase — `updateItem` only patches an
140
+ existing record; use `startChat` to ask the agent to add a new one.
141
+
142
+ ### Starting a chat — `startChat`
143
+
144
+ Identical to the desktop helper: opens a **new chat with your prompt prefilled
145
+ as an editable draft** — it does NOT auto-send; the user reviews and sends.
146
+ This is the only way a remote view "does" anything beyond display:
147
+
148
+ ```js
149
+ window.__MC_VIEW.startChat("Mark task " + item.id + " as done.");
150
+ ```
151
+
152
+ ### Translations — `t`
153
+
154
+ Same contract as desktop: declare `"i18n": "views/<name>.i18n.json"` in the
155
+ `views[]` entry (vue-i18n locale-message shape); the host picks the active
156
+ locale's flat dict server-side and `t(key, named)` interpolates `{name}`
157
+ placeholders, falling back to the key.
158
+
159
+ ## Design for the phone — not a shrunken desktop
160
+
161
+ The frame is a real phone viewport. **Do not miniaturize a desktop layout**
162
+ (a 12-month grid becomes an unreadable postage stamp); pick a vertically
163
+ composable idiom instead — a scrolling agenda, a card list, stacked sections.
164
+
165
+ - **Required `<head>`**: build a full document with
166
+ `<meta charset="utf-8">` and
167
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`.
168
+ - **Single column, vertical scroll.** No fixed pixel widths, no horizontal
169
+ scroll, percentages / flex / grid over absolute positioning.
170
+ - **Touch, not pointer**: tap targets ≥ 44px tall, generous spacing, **no
171
+ hover-dependent affordances** (there is no hover).
172
+ - **Respect the notch**: pad fixed headers/footers with
173
+ `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)`.
174
+ - **System fonts** (`system-ui, sans-serif`) render instantly; heavy chart
175
+ libraries that feel fine on desktop are sluggish on a phone — prefer plain
176
+ DOM/SVG unless a chart is the point.
177
+
178
+ ## Sandbox rules (what the view may and may not do)
179
+
180
+ The view runs in a `sandbox="allow-scripts"` iframe with a CSP **stricter**
181
+ than desktop views:
182
+
183
+ - **`fetch` / XHR / WebSocket are blocked to EVERY origin** (`connect-src
184
+ 'none'`). There is nothing to fetch — records come only through
185
+ `getItems`. Never write `fetch(...)` in a remote view.
186
+ - **Inline `<script>` / `<style>` work**, and external scripts/styles/fonts
187
+ may load from the same curated CDNs as desktop views (`cdn.jsdelivr.net`,
188
+ `unpkg.com`, `cdnjs.cloudflare.com`, Google Fonts, `cdn.plot.ly`) — the
189
+ phone has internet; only this machine is unreachable. Prefer self-contained
190
+ HTML anyway (see the size budget).
191
+ - **`<img>` / `<audio>` / `<video>` may load any `https:` URL** (plus `data:`
192
+ / `blob:`) — a record's public image/media URL renders. A **workspace file
193
+ path does not** (it lives on this machine, which the phone can't reach):
194
+ treat `image`-type fields as desktop-only and skip or placeholder them.
195
+ - **Outbound links**: `<a href="…" target="_blank" rel="noopener">` opens
196
+ normally; a same-tab `<a href>` is blocked by the sandbox.
197
+ - No cookies, no `localStorage`, no parent access — and no token exists in
198
+ this contract at all.
199
+
200
+ ## Editing / iterating
201
+
202
+ Read + Edit the `views/<name>.html` file under the collection's skill dir,
203
+ exactly like a desktop view; the desktop preview re-renders on reload. To
204
+ remove one, delete the file and its `views[]` entry.
205
+
206
+ ---
207
+
208
+ ## Example — mobile card list with "Load more"
209
+
210
+ A phone-first record list: single column, 44px+ tap targets, paginated through
211
+ `getItems`, a `startChat` action per card. Schema has `title` (string),
212
+ `status` (enum), `start` (date). Registration:
213
+ `{ "id": "phone", "label": "Phone", "target": "mobile", "file": "views/phone.html" }`.
214
+
215
+ `data/skills/annual-plan/views/phone.html`:
216
+
217
+ ```html
218
+ <!doctype html>
219
+ <html>
220
+ <head>
221
+ <meta charset="utf-8" />
222
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
223
+ <style>
224
+ body {
225
+ font-family: system-ui, sans-serif;
226
+ margin: 0;
227
+ padding: 12px;
228
+ padding-bottom: calc(12px + env(safe-area-inset-bottom));
229
+ background: #f8fafc;
230
+ color: #1e293b;
231
+ }
232
+ h1 {
233
+ font-size: 17px;
234
+ margin: 4px 4px 12px;
235
+ }
236
+ .card {
237
+ background: white;
238
+ border: 1px solid #e2e8f0;
239
+ border-radius: 12px;
240
+ padding: 12px 14px;
241
+ margin-bottom: 10px;
242
+ min-height: 44px;
243
+ }
244
+ .card b {
245
+ font-size: 15px;
246
+ }
247
+ .meta {
248
+ display: flex;
249
+ gap: 8px;
250
+ margin-top: 6px;
251
+ font-size: 13px;
252
+ color: #64748b;
253
+ align-items: center;
254
+ }
255
+ .badge {
256
+ background: #eef2ff;
257
+ color: #3730a3;
258
+ border-radius: 999px;
259
+ padding: 2px 10px;
260
+ font-size: 12px;
261
+ }
262
+ button {
263
+ min-height: 44px;
264
+ border: 0;
265
+ border-radius: 10px;
266
+ font-size: 15px;
267
+ font-weight: 600;
268
+ }
269
+ .chat {
270
+ margin-left: auto;
271
+ background: #f1f5f9;
272
+ color: #334155;
273
+ padding: 0 14px;
274
+ }
275
+ .more {
276
+ width: 100%;
277
+ background: #4f46e5;
278
+ color: white;
279
+ margin-top: 4px;
280
+ }
281
+ .err {
282
+ color: #b91c1c;
283
+ padding: 12px;
284
+ }
285
+ </style>
286
+ </head>
287
+ <body>
288
+ <h1>Annual plan</h1>
289
+ <div id="list"></div>
290
+ <button id="more" class="more" hidden>Load more</button>
291
+ <script>
292
+ var FIELDS = ["title", "status", "start"]; // project ONLY what we render
293
+ var PAGE = 50;
294
+ var loaded = [];
295
+ var total = 0;
296
+
297
+ function card(item) {
298
+ var el = document.createElement("div");
299
+ el.className = "card";
300
+ var meta =
301
+ '<div class="meta"><span class="badge">' +
302
+ (item.status || "-") +
303
+ "</span><span>" +
304
+ (item.start || "") +
305
+ "</span>" +
306
+ '<button class="chat" data-id="' +
307
+ item.id +
308
+ '">Chat</button></div>';
309
+ el.innerHTML = "<b></b>" + meta;
310
+ el.querySelector("b").textContent = item.title || item.id;
311
+ return el;
312
+ }
313
+
314
+ function render(items) {
315
+ var list = document.getElementById("list");
316
+ items.forEach(function (item) {
317
+ list.appendChild(card(item));
318
+ });
319
+ document.getElementById("more").hidden = loaded.length >= total;
320
+ }
321
+
322
+ async function loadMore() {
323
+ var page = await window.__MC_VIEW.getItems({ offset: loaded.length, limit: PAGE, fields: FIELDS });
324
+ total = page.total;
325
+ loaded = loaded.concat(page.items);
326
+ render(page.items);
327
+ }
328
+
329
+ document.getElementById("more").onclick = function () {
330
+ loadMore().catch(showError);
331
+ };
332
+ document.getElementById("list").onclick = function (e) {
333
+ var btn = e.target.closest(".chat");
334
+ if (!btn) return;
335
+ var item = loaded.find(function (r) {
336
+ return String(r.id) === btn.dataset.id;
337
+ });
338
+ if (item) window.__MC_VIEW.startChat("Give me an update plan for: " + (item.title || item.id));
339
+ };
340
+
341
+ function showError(err) {
342
+ document.getElementById("list").innerHTML = '<div class="err">Could not load: ' + err.message + "</div>";
343
+ }
344
+ loadMore().catch(showError);
345
+ </script>
346
+ </body>
347
+ </html>
348
+ ```
349
+
350
+ ## Example — writable todo (toggle `done`, delete)
351
+
352
+ A phone-first todo list that **checks off** and **deletes** records. Schema has
353
+ `title` (string) and `done` (boolean). Registration declares the write surface:
354
+ `{ "id": "phone", "label": "Todos", "target": "mobile", "file": "views/phone.html", "editableFields": ["done"], "allowDelete": true }`.
355
+
356
+ The core of `views/phone.html` (styles/`<head>` as above):
357
+
358
+ ```html
359
+ <div id="list"></div>
360
+ <script>
361
+ var items = [];
362
+
363
+ function row(item) {
364
+ var el = document.createElement("div");
365
+ el.className = "card";
366
+ el.innerHTML =
367
+ '<label class="meta"><input type="checkbox" data-toggle="' +
368
+ item.id +
369
+ '"' +
370
+ (item.done ? " checked" : "") +
371
+ ' style="width:22px;height:22px"><b></b>' +
372
+ '<button class="chat" data-del="' +
373
+ item.id +
374
+ '" style="margin-left:auto">Delete</button></label>';
375
+ el.querySelector("b").textContent = item.title || item.id;
376
+ if (item.done) el.style.opacity = 0.5;
377
+ return el;
378
+ }
379
+
380
+ function render() {
381
+ var list = document.getElementById("list");
382
+ list.innerHTML = "";
383
+ items.forEach(function (item) {
384
+ list.appendChild(row(item));
385
+ });
386
+ }
387
+
388
+ async function load() {
389
+ var page = await window.__MC_VIEW.getItems({ offset: 0, limit: 200, fields: ["title", "done"] });
390
+ items = page.items;
391
+ render();
392
+ }
393
+
394
+ document.getElementById("list").addEventListener("change", async function (e) {
395
+ var id = e.target.dataset.toggle;
396
+ if (!id) return;
397
+ try {
398
+ // Host merges the patch and returns the merged record; reflect it.
399
+ var res = await window.__MC_VIEW.updateItem(id, { done: e.target.checked });
400
+ var i = items.findIndex(function (r) {
401
+ return String(r.id) === id;
402
+ });
403
+ if (i >= 0) items[i] = res.item;
404
+ render();
405
+ } catch (err) {
406
+ e.target.checked = !e.target.checked; // revert on refusal
407
+ alert(err.message);
408
+ }
409
+ });
410
+
411
+ document.getElementById("list").addEventListener("click", async function (e) {
412
+ var id = e.target.dataset.del;
413
+ if (!id) return;
414
+ try {
415
+ await window.__MC_VIEW.deleteItem(id);
416
+ items = items.filter(function (r) {
417
+ return String(r.id) !== id;
418
+ });
419
+ render();
420
+ } catch (err) {
421
+ alert(err.message);
422
+ }
423
+ });
424
+
425
+ load().catch(function (err) {
426
+ document.getElementById("list").innerHTML = '<div class="err">' + err.message + "</div>";
427
+ });
428
+ </script>
429
+ ```
@@ -7,7 +7,10 @@ collection's data. The user asks for it in plain language ("give me a view that
7
7
  shows my whole year"); you author the HTML and register it.
8
8
 
9
9
  Read `config/helps/collection-skills.md` first for the collection/schema DSL.
10
- This file is only about the **view** layer.
10
+ This file is only about the **desktop view** layer. For a view that runs on the
11
+ **phone remote app** (`target: "mobile"` in the `views[]` entry), read
12
+ `config/helps/custom-view-remote.md` instead — its runtime contract is
13
+ different and incompatible (no token, no dataUrl, no fetch).
11
14
 
12
15
  ## Where the files go
13
16
 
@@ -32,9 +35,7 @@ the collection's `schema.json`:
32
35
  "icon": "calendar_month",
33
36
  "dataPath": "data/annual-plan/items",
34
37
  "primaryKey": "id",
35
- "fields": {
36
- /* … */
37
- },
38
+ "fields": {/* … */},
38
39
  "views": [
39
40
  { "id": "year", "label": "Year", "icon": "grid_view", "file": "views/year.html", "capabilities": ["read"] },
40
41
  { "id": "planner", "label": "Planner", "icon": "edit_calendar", "file": "views/planner.html", "capabilities": ["read", "write"] },
@@ -43,7 +43,11 @@ See [Collection skills](config/helps/collection-skills.md) for the full schema D
43
43
 
44
44
  When the built-in table / calendar / kanban / dashboard views don't fit what you want to _see_ — a year-at-a-glance planner, a Gantt bar, a heat-map, a printable report — Claude authors a **custom view**: a single HTML file rendered in a sandboxed iframe over the collection's records. It reads (and optionally writes) records through a scoped token, stays live as the data changes, and can hand work back to a chat — all without any view-specific host code. The view is data, just like the rest of the collection, so you can ask for an entirely new way to look at your data in plain language and get it.
45
45
 
46
- See [Custom views](config/helps/custom-view.md) for the authoring contract.
46
+ See [Custom views](config/helps/custom-view.md) for the authoring contract. A
47
+ view can also target the **mobile remote app** (`target: "mobile"`) — rendered
48
+ on the user's phone and previewed on the desktop in a phone-sized frame; that
49
+ variant has a different, incompatible runtime contract described in
50
+ [Remote custom views](config/helps/custom-view-remote.md).
47
51
 
48
52
  ## Wiki — Long-Term Memory
49
53
 
@@ -69,6 +73,7 @@ See [Wiki](config/helps/wiki.md) for details on how it works.
69
73
  - [GitHub repositories in the workspace](config/helps/github.md) — clone-destination rules under `github/<name>/` and how to handle existing directories with matching or different remotes
70
74
  - [Collection skills](config/helps/collection-skills.md) — build a data app (model + UI + relations + computed fields + action buttons) by authoring a `schema.json` collection skill: the DSL, field types, derived formulas, actions, records
71
75
  - [Custom views](config/helps/custom-view.md) — give a collection a view the built-ins don't cover (year/quarter overview, Gantt): an HTML file under `views/`, registered in `schema.json`, rendered in a sandboxed iframe over the records
76
+ - [Remote custom views](config/helps/custom-view-remote.md) — author a collection view for the **phone remote app** (`target: "mobile"` in `views[]`): records via the `getItems` bridge (no token, no fetch), mobile-first layout rules, phone-frame preview
72
77
  - [Todo list collection](config/helps/todo-collection.md) — the canonical recipe for building or migrating a todo / task list: full schema (status enum + `done` toggle + priority bells), `SKILL.md`, and legacy `todo-plugin` migration steps
73
78
  - [Vocabulary collection](config/helps/vocabulary.md) — recipe for a language-learning word deck (any language): `proficiency` enum + `mastered` toggle + `meaning`/`example` fields, a kanban for drag-to-promote review, and bulk-add / quiz workflows
74
79
  - [Lessons collection](config/helps/lessons-collection.md) — recipe for a multi-session **curriculum** (any topic): one lesson per record, a `status` kanban, a `file` link to each lesson's HTML, and per-lesson + course-level **Learn** actions run by the Tutor
@@ -176,6 +176,21 @@ export interface CollectionCustomView {
176
176
  * (least privilege); declare `["read","write"]` only for views that
177
177
  * edit records. The mint endpoint clamps any requested caps to this. */
178
178
  capabilities?: CollectionViewCapability[];
179
+ /** Where the view runs. Absent ⇒ `"desktop"` (this token/dataUrl contract).
180
+ * `"mobile"` ⇒ a remote view for the phone client: served through the
181
+ * command channel's `getRemoteView` over the postMessage contract
182
+ * (`@mulmoclaude/core/remote-view` — no token, `connect-src 'none'`) and
183
+ * previewed on desktop inside a phone-sized frame. */
184
+ target?: "desktop" | "mobile";
185
+ /** **Mobile-only** (ignored for desktop views, which use token-scoped
186
+ * `capabilities`). The whitelist of field names a `target: "mobile"` view
187
+ * may patch via `__MC_VIEW.updateItem(id, patch)`. Default-deny: absent or
188
+ * empty ⇒ updates are refused host-side. Never include the primary key.
189
+ * See plans/feat-remote-writable-view.md. */
190
+ editableFields?: string[];
191
+ /** **Mobile-only.** When `true`, a `target: "mobile"` view may remove a
192
+ * record via `__MC_VIEW.deleteItem(id)`. Absent/`false` ⇒ deletes refused. */
193
+ allowDelete?: boolean;
179
194
  }
180
195
  /** A schema-declared, per-record action rendered as a button in the
181
196
  * read-only detail view. Pure UI/behaviour directive — never stored,
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_rolldown_runtime = require("../../../rolldown-runtime-D6vf50IK.cjs");
3
- const require_server = require("../../../server-cmnH6g2O.cjs");
3
+ const require_server = require("../../../server-DBi5HmrM.cjs");
4
4
  const require_collection_paths = require("../../paths.cjs");
5
5
  const require_types = require("../../../types-CNqkLT4M.cjs");
6
6
  const require_skill_bridge_index = require("../../../skill-bridge/index.cjs");
@@ -1,4 +1,4 @@
1
- import { K as collectionsRegistriesConfigPath, L as writeFileAtomic, W as safeRecordId, Y as log, _ as acceptParsedSchema, g as CollectionSchemaZ, m as errorMessage, p as ONE_SECOND_MS, y as loadCollection } from "../../../server-D44bvGjw.js";
1
+ import { K as collectionsRegistriesConfigPath, L as writeFileAtomic, W as safeRecordId, Y as log, _ as acceptParsedSchema, g as CollectionSchemaZ, m as errorMessage, p as ONE_SECOND_MS, y as loadCollection } from "../../../server-DgAdWcCM.js";
2
2
  import { isSafeActionTemplatePath } from "../../paths.js";
3
3
  import { n as parseRegistryIndex, r as isRecord, t as OFFICIAL_REGISTRY_NAME } from "../../../types-BKVZrtyc.js";
4
4
  import { claudeSkillDir, dataSkillDir, mirrorSkillWrite } from "../../../skill-bridge/index.js";
@@ -143,12 +143,18 @@ export declare const CollectionSchemaZ: z.ZodObject<{
143
143
  id: z.ZodString;
144
144
  label: z.ZodString;
145
145
  icon: z.ZodOptional<z.ZodString>;
146
+ target: z.ZodOptional<z.ZodEnum<{
147
+ desktop: "desktop";
148
+ mobile: "mobile";
149
+ }>>;
146
150
  file: z.ZodString;
147
151
  i18n: z.ZodOptional<z.ZodString>;
148
152
  capabilities: z.ZodOptional<z.ZodArray<z.ZodEnum<{
149
153
  read: "read";
150
154
  write: "write";
151
155
  }>>>;
156
+ editableFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
157
+ allowDelete: z.ZodOptional<z.ZodBoolean>;
152
158
  }, z.core.$strip>>>;
153
159
  notifyWhen: z.ZodOptional<z.ZodObject<{
154
160
  field: z.ZodString;
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_server = require("../../server-cmnH6g2O.cjs");
2
+ const require_server = require("../../server-DBi5HmrM.cjs");
3
3
  const require_collection_paths = require("../paths.cjs");
4
4
  exports.COMPUTED_TYPES = require_server.COMPUTED_TYPES;
5
5
  exports.CollectionSchemaZ = require_server.CollectionSchemaZ;