@mulmoclaude/core 0.5.0 → 0.6.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,293 @@
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
+ - **No `capabilities`** — remote views are **read-only** in this phase. There
31
+ is no write path; a button that should _do_ something uses `startChat`.
32
+
33
+ Feed collections register theirs in `feeds/<slug>/schema.json` with the HTML
34
+ at `feeds/<slug>/views/<name>.html`, like desktop views.
35
+
36
+ ## Previewing without a phone
37
+
38
+ After you Write + register the view, its button appears in the collection's
39
+ view selector **on the desktop** and renders inside a **phone-sized frame
40
+ (390×844)** — the exact same wrapped document the phone receives, with the same
41
+ sandbox rules and the same data bridge. A caption under the frame shows the
42
+ file's size against its **900 KB budget** (the view travels to the phone inside
43
+ a size-capped message — stay well under it; a normal view is tens of KB).
44
+ Iterate against this preview; if it works there, it works on the phone.
45
+
46
+ ## The runtime contract — `window.__MC_VIEW`
47
+
48
+ The host injects a bootstrap into your page **before any of your scripts run**:
49
+
50
+ ```js
51
+ window.__MC_VIEW = {
52
+ slug: "annual-plan", // this collection
53
+ locale: "en", // active app locale ("" when no translations)
54
+ target: "mobile",
55
+ protocol: 1,
56
+ getItems: (opts) => Promise, // the ONLY way to read records — see below
57
+ startChat: (prompt, role) => void, // draft a new chat for the user
58
+ t: (key, named) => string, // vue-i18n-style dict lookup (same as desktop)
59
+ };
60
+ ```
61
+
62
+ What is deliberately **absent** compared to the desktop contract: `token`,
63
+ `dataUrl` (nothing to fetch), `onChange` (no live refresh yet — render on
64
+ load), and `openItem` (no host record panel on the phone yet).
65
+
66
+ ### Reading records — `getItems` (paginated, always)
67
+
68
+ ```js
69
+ const page = await window.__MC_VIEW.getItems({
70
+ offset: 0,
71
+ limit: 50, // clamped to [1, 200]
72
+ fields: ["title", "start", "status"], // project — list ONLY what you render
73
+ });
74
+ // page = { items: [...], total: 123, offset: 0, limit: 50 }
75
+ ```
76
+
77
+ - **Pagination is mandatory.** Each page travels to the phone through a
78
+ size-capped channel; you can never assume one call returns everything.
79
+ Render the first page, then offer a **"Load more"** affordance while
80
+ `items.length < total` (see the example).
81
+ - **Always pass `fields`.** The projection keeps pages small (the primary key
82
+ is always included). List the columns your view actually renders.
83
+ - The promise **rejects** on failure or after a 30 s timeout — catch it and
84
+ show the message; don't fail silently.
85
+ - Records come back with computed fields already resolved, like desktop.
86
+
87
+ ### Starting a chat — `startChat`
88
+
89
+ Identical to the desktop helper: opens a **new chat with your prompt prefilled
90
+ as an editable draft** — it does NOT auto-send; the user reviews and sends.
91
+ This is the only way a remote view "does" anything beyond display:
92
+
93
+ ```js
94
+ window.__MC_VIEW.startChat("Mark task " + item.id + " as done.");
95
+ ```
96
+
97
+ ### Translations — `t`
98
+
99
+ Same contract as desktop: declare `"i18n": "views/<name>.i18n.json"` in the
100
+ `views[]` entry (vue-i18n locale-message shape); the host picks the active
101
+ locale's flat dict server-side and `t(key, named)` interpolates `{name}`
102
+ placeholders, falling back to the key.
103
+
104
+ ## Design for the phone — not a shrunken desktop
105
+
106
+ The frame is a real phone viewport. **Do not miniaturize a desktop layout**
107
+ (a 12-month grid becomes an unreadable postage stamp); pick a vertically
108
+ composable idiom instead — a scrolling agenda, a card list, stacked sections.
109
+
110
+ - **Required `<head>`**: build a full document with
111
+ `<meta charset="utf-8">` and
112
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`.
113
+ - **Single column, vertical scroll.** No fixed pixel widths, no horizontal
114
+ scroll, percentages / flex / grid over absolute positioning.
115
+ - **Touch, not pointer**: tap targets ≥ 44px tall, generous spacing, **no
116
+ hover-dependent affordances** (there is no hover).
117
+ - **Respect the notch**: pad fixed headers/footers with
118
+ `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)`.
119
+ - **System fonts** (`system-ui, sans-serif`) render instantly; heavy chart
120
+ libraries that feel fine on desktop are sluggish on a phone — prefer plain
121
+ DOM/SVG unless a chart is the point.
122
+
123
+ ## Sandbox rules (what the view may and may not do)
124
+
125
+ The view runs in a `sandbox="allow-scripts"` iframe with a CSP **stricter**
126
+ than desktop views:
127
+
128
+ - **`fetch` / XHR / WebSocket are blocked to EVERY origin** (`connect-src
129
+ 'none'`). There is nothing to fetch — records come only through
130
+ `getItems`. Never write `fetch(...)` in a remote view.
131
+ - **Inline `<script>` / `<style>` work**, and external scripts/styles/fonts
132
+ may load from the same curated CDNs as desktop views (`cdn.jsdelivr.net`,
133
+ `unpkg.com`, `cdnjs.cloudflare.com`, Google Fonts, `cdn.plot.ly`) — the
134
+ phone has internet; only this machine is unreachable. Prefer self-contained
135
+ HTML anyway (see the size budget).
136
+ - **`<img>` / `<audio>` / `<video>` may load any `https:` URL** (plus `data:`
137
+ / `blob:`) — a record's public image/media URL renders. A **workspace file
138
+ path does not** (it lives on this machine, which the phone can't reach):
139
+ treat `image`-type fields as desktop-only and skip or placeholder them.
140
+ - **Outbound links**: `<a href="…" target="_blank" rel="noopener">` opens
141
+ normally; a same-tab `<a href>` is blocked by the sandbox.
142
+ - No cookies, no `localStorage`, no parent access — and no token exists in
143
+ this contract at all.
144
+
145
+ ## Editing / iterating
146
+
147
+ Read + Edit the `views/<name>.html` file under the collection's skill dir,
148
+ exactly like a desktop view; the desktop preview re-renders on reload. To
149
+ remove one, delete the file and its `views[]` entry.
150
+
151
+ ---
152
+
153
+ ## Example — mobile card list with "Load more"
154
+
155
+ A phone-first record list: single column, 44px+ tap targets, paginated through
156
+ `getItems`, a `startChat` action per card. Schema has `title` (string),
157
+ `status` (enum), `start` (date). Registration:
158
+ `{ "id": "phone", "label": "Phone", "target": "mobile", "file": "views/phone.html" }`.
159
+
160
+ `data/skills/annual-plan/views/phone.html`:
161
+
162
+ ```html
163
+ <!doctype html>
164
+ <html>
165
+ <head>
166
+ <meta charset="utf-8" />
167
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
168
+ <style>
169
+ body {
170
+ font-family: system-ui, sans-serif;
171
+ margin: 0;
172
+ padding: 12px;
173
+ padding-bottom: calc(12px + env(safe-area-inset-bottom));
174
+ background: #f8fafc;
175
+ color: #1e293b;
176
+ }
177
+ h1 {
178
+ font-size: 17px;
179
+ margin: 4px 4px 12px;
180
+ }
181
+ .card {
182
+ background: white;
183
+ border: 1px solid #e2e8f0;
184
+ border-radius: 12px;
185
+ padding: 12px 14px;
186
+ margin-bottom: 10px;
187
+ min-height: 44px;
188
+ }
189
+ .card b {
190
+ font-size: 15px;
191
+ }
192
+ .meta {
193
+ display: flex;
194
+ gap: 8px;
195
+ margin-top: 6px;
196
+ font-size: 13px;
197
+ color: #64748b;
198
+ align-items: center;
199
+ }
200
+ .badge {
201
+ background: #eef2ff;
202
+ color: #3730a3;
203
+ border-radius: 999px;
204
+ padding: 2px 10px;
205
+ font-size: 12px;
206
+ }
207
+ button {
208
+ min-height: 44px;
209
+ border: 0;
210
+ border-radius: 10px;
211
+ font-size: 15px;
212
+ font-weight: 600;
213
+ }
214
+ .chat {
215
+ margin-left: auto;
216
+ background: #f1f5f9;
217
+ color: #334155;
218
+ padding: 0 14px;
219
+ }
220
+ .more {
221
+ width: 100%;
222
+ background: #4f46e5;
223
+ color: white;
224
+ margin-top: 4px;
225
+ }
226
+ .err {
227
+ color: #b91c1c;
228
+ padding: 12px;
229
+ }
230
+ </style>
231
+ </head>
232
+ <body>
233
+ <h1>Annual plan</h1>
234
+ <div id="list"></div>
235
+ <button id="more" class="more" hidden>Load more</button>
236
+ <script>
237
+ var FIELDS = ["title", "status", "start"]; // project ONLY what we render
238
+ var PAGE = 50;
239
+ var loaded = [];
240
+ var total = 0;
241
+
242
+ function card(item) {
243
+ var el = document.createElement("div");
244
+ el.className = "card";
245
+ var meta =
246
+ '<div class="meta"><span class="badge">' +
247
+ (item.status || "-") +
248
+ "</span><span>" +
249
+ (item.start || "") +
250
+ "</span>" +
251
+ '<button class="chat" data-id="' +
252
+ item.id +
253
+ '">Chat</button></div>';
254
+ el.innerHTML = "<b></b>" + meta;
255
+ el.querySelector("b").textContent = item.title || item.id;
256
+ return el;
257
+ }
258
+
259
+ function render(items) {
260
+ var list = document.getElementById("list");
261
+ items.forEach(function (item) {
262
+ list.appendChild(card(item));
263
+ });
264
+ document.getElementById("more").hidden = loaded.length >= total;
265
+ }
266
+
267
+ async function loadMore() {
268
+ var page = await window.__MC_VIEW.getItems({ offset: loaded.length, limit: PAGE, fields: FIELDS });
269
+ total = page.total;
270
+ loaded = loaded.concat(page.items);
271
+ render(page.items);
272
+ }
273
+
274
+ document.getElementById("more").onclick = function () {
275
+ loadMore().catch(showError);
276
+ };
277
+ document.getElementById("list").onclick = function (e) {
278
+ var btn = e.target.closest(".chat");
279
+ if (!btn) return;
280
+ var item = loaded.find(function (r) {
281
+ return String(r.id) === btn.dataset.id;
282
+ });
283
+ if (item) window.__MC_VIEW.startChat("Give me an update plan for: " + (item.title || item.id));
284
+ };
285
+
286
+ function showError(err) {
287
+ document.getElementById("list").innerHTML = '<div class="err">Could not load: ' + err.message + "</div>";
288
+ }
289
+ loadMore().catch(showError);
290
+ </script>
291
+ </body>
292
+ </html>
293
+ ```
@@ -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,12 @@ 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";
179
185
  }
180
186
  /** A schema-declared, per-record action rendered as a button in the
181
187
  * 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-DoDXibCq.cjs");
3
+ const require_server = require("../../../server-Ch1cWphH.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 { G as collectionsRegistriesConfigPath, I as writeFileAtomic, J as log, U as safeRecordId, _ as acceptParsedSchema, g as CollectionSchemaZ, m as errorMessage, p as ONE_SECOND_MS, y as loadCollection } from "../../../server-DRoqc8dL.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-_fgRpzVL.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,6 +143,10 @@ 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<{
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_server = require("../../server-DoDXibCq.cjs");
2
+ const require_server = require("../../server-Ch1cWphH.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;
@@ -33,6 +33,7 @@ exports.loadCollection = require_server.loadCollection;
33
33
  exports.log = require_server.log;
34
34
  exports.maybeSpawnSuccessor = require_server.maybeSpawnSuccessor;
35
35
  exports.parseCivil = require_server.parseCivil;
36
+ exports.promptPathsFor = require_server.promptPathsFor;
36
37
  exports.publishCollectionChange = require_server.publishCollectionChange;
37
38
  exports.readCustomViewHtml = require_server.readCustomViewHtml;
38
39
  exports.readCustomViewI18n = require_server.readCustomViewI18n;
@@ -1,3 +1,3 @@
1
- import { A as readCustomViewHtml, B as itemFilePath, C as validateCollectionRecords, D as deleteItem, E as buildCollectionActionSeedPrompt, F as writeItem, H as resolveTemplatePath, J as log, K as configureCollectionHost, L as SCHEMA_FILE, M as readItem, N as readSkillTemplate, O as generateItemId, P as resolveCreateItemId, R as isContainedInRoot, S as COMPUTED_TYPES, T as buildActionSeedPrompt, U as safeRecordId, V as resolveDataDir, W as safeSlugName, X as setCollectionChangePublisher, Y as publishCollectionChange, _ as acceptParsedSchema, a as computeSuccessor, b as toDetail, c as isTriggerDue, d as resolveEvery, f as successorId, g as CollectionSchemaZ, h as enrichItems, i as advanceTriggerDate, j as readCustomViewI18n, k as listItems, l as maybeSpawnSuccessor, n as deleteCollection, o as daysInMonth, q as getWorkspaceRoot, r as deleteCollectionRefusalMessage, s as formatCivil, t as deleteCustomView, u as parseCivil, v as discoverCollections, w as validateRecordObject, x as toSummary, y as loadCollection, z as isContainedInWorkspace } from "../../server-DRoqc8dL.js";
1
+ import { A as promptPathsFor, B as isContainedInWorkspace, C as validateCollectionRecords, D as deleteItem, E as buildCollectionActionSeedPrompt, F as resolveCreateItemId, G as safeSlugName, H as resolveDataDir, I as writeItem, J as getWorkspaceRoot, M as readCustomViewI18n, N as readItem, O as generateItemId, P as readSkillTemplate, R as SCHEMA_FILE, S as COMPUTED_TYPES, T as buildActionSeedPrompt, U as resolveTemplatePath, V as itemFilePath, W as safeRecordId, X as publishCollectionChange, Y as log, Z as setCollectionChangePublisher, _ as acceptParsedSchema, a as computeSuccessor, b as toDetail, c as isTriggerDue, d as resolveEvery, f as successorId, g as CollectionSchemaZ, h as enrichItems, i as advanceTriggerDate, j as readCustomViewHtml, k as listItems, l as maybeSpawnSuccessor, n as deleteCollection, o as daysInMonth, q as configureCollectionHost, r as deleteCollectionRefusalMessage, s as formatCivil, t as deleteCustomView, u as parseCivil, v as discoverCollections, w as validateRecordObject, x as toSummary, y as loadCollection, z as isContainedInRoot } from "../../server-_fgRpzVL.js";
2
2
  import { isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath, isSafeTemplatePath } from "../paths.js";
3
- export { COMPUTED_TYPES, CollectionSchemaZ, SCHEMA_FILE, acceptParsedSchema, advanceTriggerDate, buildActionSeedPrompt, buildCollectionActionSeedPrompt, computeSuccessor, configureCollectionHost, daysInMonth, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, enrichItems, formatCivil, generateItemId, getWorkspaceRoot, isContainedInRoot, isContainedInWorkspace, isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, maybeSpawnSuccessor, parseCivil, publishCollectionChange, readCustomViewHtml, readCustomViewI18n, readItem, readSkillTemplate, resolveCreateItemId, resolveDataDir, resolveEvery, resolveTemplatePath, safeRecordId, safeSlugName, setCollectionChangePublisher, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
3
+ export { COMPUTED_TYPES, CollectionSchemaZ, SCHEMA_FILE, acceptParsedSchema, advanceTriggerDate, buildActionSeedPrompt, buildCollectionActionSeedPrompt, computeSuccessor, configureCollectionHost, daysInMonth, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, enrichItems, formatCivil, generateItemId, getWorkspaceRoot, isContainedInRoot, isContainedInWorkspace, isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, maybeSpawnSuccessor, parseCivil, promptPathsFor, publishCollectionChange, readCustomViewHtml, readCustomViewI18n, readItem, readSkillTemplate, resolveCreateItemId, resolveDataDir, resolveEvery, resolveTemplatePath, safeRecordId, safeSlugName, setCollectionChangePublisher, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
@@ -133,15 +133,43 @@ export declare function resolveCreateItemId(schema: CollectionSchema, record: Co
133
133
  * the file contents, or null when the path escapes the skill dir, the
134
134
  * resolved target isn't a regular file, or the read fails. */
135
135
  export declare function readSkillTemplate(skillDir: string, templateRelPath: string): Promise<string | null>;
136
+ /** Host-controlled canonical paths threaded into the seed prompt so a
137
+ * template that shells out to a bundled ingest script (e.g. `python3
138
+ * data/<slug>/fetch.py`) can point at the paths the host actually reads
139
+ * from — not the (author-declared) paths the script's `--out-dir` default
140
+ * bakes in. `dataPath` is the R3-normalized value (`data/collections/<slug>/
141
+ * items`), NOT whatever the author wrote in schema.json before import.
142
+ * `skillDir` and `dataPath` are meant to be workspace-relative so shell
143
+ * commands run against the workspace CWD without ceremony. `slug` is the
144
+ * post-rename local slug for scripts that need to log / self-identify.
145
+ * All three are host-derived (never user-writable input), so they don't
146
+ * need `sanitizeDeep` for injection defense — the standard `<`-escape on
147
+ * the JSON block covers a hypothetical hostile character. Fixes #1891. */
148
+ export interface CollectionPromptPaths {
149
+ slug: string;
150
+ dataPath: string;
151
+ skillDir: string;
152
+ }
153
+ /** Build the paths block from a discovered collection. `skillDir` is
154
+ * converted to workspace-relative so shell / script invocations compose
155
+ * cleanly with the workspace-relative `dataPath`; if the skill lives outside
156
+ * the workspace (user-scope collection under `~/.claude/skills/`), the
157
+ * absolute path is emitted so the agent can still address it. `dataPath` is
158
+ * read straight from the schema — post-R3-normalization for imported
159
+ * collections, so it reflects what the host actually reads/writes. */
160
+ export declare function promptPathsFor(collection: Pick<LoadedCollection, "slug" | "schema" | "skillDir">, workspaceRoot: string): CollectionPromptPaths;
136
161
  /** Build the seed prompt for a `kind: "chat"` action: a security-
137
- * boundary instruction + the record as a sanitized JSON data block +
138
- * the template text verbatim. Pure + exported for tests. Domain-free —
139
- * the template (skill-owned) carries every specific instruction; the
140
- * host only injects the record's own data. */
141
- export declare function buildActionSeedPrompt(record: CollectionItem, templateText: string): string;
162
+ * boundary instruction + optional host paths block + the record as a
163
+ * sanitized JSON data block + the template text verbatim. Pure + exported
164
+ * for tests. Domain-free — the template (skill-owned) carries every
165
+ * specific instruction; the host only injects the record's own data and
166
+ * its canonical paths. */
167
+ export declare function buildActionSeedPrompt(record: CollectionItem, templateText: string, paths?: CollectionPromptPaths): string;
142
168
  /** Build the seed prompt for a collection-level `kind: "chat"` action: a
143
- * security-boundary instruction + a compact progress summary of every
144
- * record (see `progressSummary`) + the template verbatim. Pure +
145
- * exported for tests. Domain-free — the template carries the specifics. */
146
- export declare function buildCollectionActionSeedPrompt(items: CollectionItem[], schema: CollectionSchema, templateText: string): string;
169
+ * security-boundary instruction + optional host paths block + a compact
170
+ * progress summary of every record (see `progressSummary`) + the template
171
+ * verbatim. Pure + exported for tests. Domain-free — the template carries
172
+ * the specifics. The paths arg (#1891) plugs the R3-normalization gap so
173
+ * ingest scripts write to the location the host actually reads from. */
174
+ export declare function buildCollectionActionSeedPrompt(items: CollectionItem[], schema: CollectionSchema, templateText: string, paths?: CollectionPromptPaths): string;
147
175
  export {};
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_collection_index = require("../collection/index.cjs");
3
- const require_server = require("../server-DoDXibCq.cjs");
3
+ const require_server = require("../server-Ch1cWphH.cjs");
4
4
  const require_notifier = require("../notifier-bS8IEeLA.cjs");
5
5
  let node_fs = require("node:fs");
6
6
  let node_fs_promises = require("node:fs/promises");
@@ -1,5 +1,5 @@
1
1
  import { whenMatches } from "../collection/index.js";
2
- import { M as readItem, c as isTriggerDue, k as listItems, l as maybeSpawnSuccessor, v as discoverCollections, y as loadCollection } from "../server-DRoqc8dL.js";
2
+ import { N as readItem, c as isTriggerDue, k as listItems, l as maybeSpawnSuccessor, v as discoverCollections, y as loadCollection } from "../server-_fgRpzVL.js";
3
3
  import { d as publish, m as updateForPlugin, n as clear, s as listAll } from "../notifier-ChpY0XrY.js";
4
4
  import { watch } from "node:fs";
5
5
  import { mkdir } from "node:fs/promises";