@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.
- package/assets/helps/collection-skills.md +26 -23
- package/assets/helps/custom-view-remote.md +429 -0
- package/assets/helps/custom-view.md +5 -4
- package/assets/helps/index.md +6 -1
- package/dist/collection/core/schema.d.ts +15 -0
- package/dist/collection/registry/server/index.cjs +1 -1
- package/dist/collection/registry/server/index.js +1 -1
- package/dist/collection/server/discovery.d.ts +6 -0
- package/dist/collection/server/index.cjs +1 -1
- package/dist/collection/server/index.js +1 -1
- package/dist/collection-watchers/index.cjs +1 -1
- package/dist/collection-watchers/index.js +1 -1
- package/dist/deriveAll-Cb9rWjan.cjs.map +1 -1
- package/dist/deriveAll-D3wFH4Tw.js.map +1 -1
- package/dist/feeds/server/index.cjs +1 -1
- package/dist/feeds/server/index.js +1 -1
- package/dist/remote-view/index.cjs +269 -0
- package/dist/remote-view/index.cjs.map +1 -0
- package/dist/remote-view/index.d.ts +144 -0
- package/dist/remote-view/index.js +254 -0
- package/dist/remote-view/index.js.map +1 -0
- package/dist/{server-cmnH6g2O.cjs → server-DBi5HmrM.cjs} +7 -4
- package/dist/server-DBi5HmrM.cjs.map +1 -0
- package/dist/{server-D44bvGjw.js → server-DgAdWcCM.js} +7 -4
- package/dist/server-DgAdWcCM.js.map +1 -0
- package/package.json +9 -3
- package/dist/server-D44bvGjw.js.map +0 -1
- package/dist/server-cmnH6g2O.cjs.map +0 -1
|
@@ -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
|
|
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
|
-
|
|
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"] },
|
package/assets/helps/index.md
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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;
|