@brass-build/client 0.1.0 → 0.2.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.
Files changed (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
package/README.md CHANGED
@@ -35,23 +35,17 @@ npm install @brass-build/client
35
35
 
36
36
  ## Quickstart
37
37
 
38
- `@brass-build/client` turns a document into a live, multi-user,
39
- persistent workspace. Your app supplies the UI; Brass supplies sign-in,
40
- the file toolbar, and one subscribe-render-mutate loop per open
41
- document. A working app is mostly your own rendering and event handling
42
- wrapped around a handful of Brass calls.
43
-
44
- **Describe it to a coding agent.** An app's dashboard page shows a
45
- ready-to-paste prompt with its id already filled in. Paste it into
46
- Claude, Cursor, or another assistant; the assistant reads this README
47
- and [`AGENTS.md`](./AGENTS.md), then wires, verifies, and ships the
48
- integration. The prompt is one line naming what you want and the SDK:
38
+ The easiest way to use Brass is to tell a coding assistant to do it for
39
+ you. Paste a line like the one below into Claude, Cursor, or another
40
+ assistant. The assistant reads this README and
41
+ [`AGENTS.md`](./AGENTS.md), then wires, verifies, and ships the
42
+ integration.
49
43
 
50
44
  ```
51
45
  Build a shared checklist on @brass-build/client (NPM).
52
46
  ```
53
47
 
54
- **Or wire it by hand.** The Brass surface of an app is small:
48
+ **Or wire it by hand.**
55
49
 
56
50
  ```ts
57
51
  import { Brass } from '@brass-build/client';
@@ -87,8 +81,7 @@ await Brass.init('brass_app_...');
87
81
 
88
82
  `bindEditor` is your code: take a handle for the data you render
89
83
  (`doc.orderedMap('items', 'item_order')` for a checklist), `subscribe`
90
- to paint your list, and call `.mutate` from a click. The rendering,
91
- styling, and event handling around it are ordinary app code.
84
+ to paint your list, and call `.mutate` from a click.
92
85
  [Document lifecycle](#document-lifecycle) and
93
86
  [Editing documents](#editing-documents) show the loop in full.
94
87
 
@@ -102,8 +95,8 @@ npx @brass-build/cli publish ./dist
102
95
  ```
103
96
 
104
97
  The first publish creates the app, hosts it at
105
- `https://<slug>.onbrass.app`, and writes its id to `.brass/project.json`
106
- (the `brass_app_...` above). Your users sign in at that URL.
98
+ `https://<slug>.onbrass.app` where your users sign in, and writes the
99
+ app id (the `brass_app_...` above) to `.brass/project.json`.
107
100
 
108
101
  ## Setup
109
102
 
@@ -111,18 +104,18 @@ To take the [quickstart](#quickstart) from source to a
111
104
  signed-in, hosted app:
112
105
 
113
106
  1. **Set up the bundler.** The SDK ships a WebAssembly module, and your
114
- bundler needs to know how to load it; see
107
+ bundler needs to know how to load it. See
115
108
  [Bundler setup](#bundler-setup).
116
109
  2. **Ship it.** `npx @brass-build/cli publish ./dist` creates the app,
117
- hosts it, and returns a URL your users sign in at. A hosted bundle
118
- needs no app id; it inherits one from its serving origin. See
119
- [Ship it](#ship-it).
120
- 3. **Local dev and self-hosting need an app id.** Pass it to
121
- `Brass.init('brass_app_...')`. The first publish writes the id to
122
- `.brass/project.json`, or copy it from the app's dashboard page. A
123
- new app already allows sign-in from `localhost` / `127.0.0.1` on any
124
- port with no allowlist step; add a self-hosted origin on the app's
125
- detail page.
110
+ hosts it, and returns its sign-in URL. See [Ship it](#ship-it).
111
+ 3. **Pass an app id unless Brass hosts your bundle.** Brass resolves
112
+ the id from its own hosting origins, so a published bundle calls
113
+ `Brass.init()` with no argument. Pass the id everywhere else, in
114
+ local development and on your own origin:
115
+ `Brass.init('brass_app_...')`. Read it from `.brass/project.json`,
116
+ which the first publish writes, or copy it from your app's page in
117
+ the dashboard. A new app already allows sign-in from `localhost` /
118
+ `127.0.0.1` on any port; add your own origin on that same page.
126
119
 
127
120
  **CSP.** If (and only if) your app sends a `Content-Security-Policy`,
128
121
  extend it: `script-src` needs `'wasm-unsafe-eval'` for the SDK's
@@ -134,43 +127,38 @@ picker and share dialog, and `img-src` needs
134
127
  ### Bundler setup
135
128
 
136
129
  `@brass-build/client` ships as ES modules, loaded by a bundler (Vite,
137
- webpack, esbuild) or Node's ESM loader with `import`.
130
+ webpack) or Node's ESM loader with `import`.
138
131
 
139
- Without a WASM loader, `npm run build` fails with
140
- `[vite:wasm-fallback] ... "ESM integration proposal for Wasm" is not
141
- supported`. For Vite, install two plugins and add the SDK's own helper:
132
+ Without a WASM loader, `vite dev` fails with `"ESM integration proposal
133
+ for Wasm" is not supported currently`. Install one plugin and add the
134
+ SDK's own helper:
142
135
 
143
136
  ```sh
144
- npm install --save-dev vite-plugin-wasm vite-plugin-top-level-await
137
+ npm install --save-dev vite-plugin-wasm
145
138
  ```
146
139
 
147
140
  ```ts
148
141
  // vite.config.ts
149
142
  import { defineConfig } from 'vite';
150
143
  import wasm from 'vite-plugin-wasm';
151
- import topLevelAwait from 'vite-plugin-top-level-await';
152
144
  import { brassVite } from '@brass-build/client/vite';
153
145
 
154
146
  export default defineConfig({
155
- plugins: [wasm(), topLevelAwait(), brassVite()],
147
+ plugins: [wasm(), brassVite()],
156
148
  build: { target: 'es2022' },
157
149
  });
158
150
  ```
159
151
 
160
152
  `brassVite()` configures Vite's dep-prebundling step so the SDK's WASM
161
- runtime loads on first `vite dev`. Vite 5/6 need
162
- `vite-plugin-top-level-await`; Vite 8's rolldown bundler supports
163
- top-level await natively. Webpack 5 needs `experiments.asyncWebAssembly: true` plus a
164
- matching top-level-await plugin. The WASM bundle is ~3 MB uncompressed
165
- (~600 KB gzipped).
153
+ runtime loads on first `vite dev`, and it names the SDK's WebAssembly
154
+ dependencies for you, so your config keeps working as they change.
155
+ Webpack 5 loads them with no extra configuration.
166
156
 
167
157
  ### Init
168
158
 
169
- The first argument to `Brass.init` is your `app_id` (a hosted bundle
170
- omits it and inherits the id from its serving origin). The second is a
171
- typed options object
172
- (`BrassInitOptions`); the defaults are right for most apps, so you
173
- usually pass nothing:
159
+ The first argument to `Brass.init` is your `app_id` ([Setup](#setup)
160
+ covers when you need one). The second is a typed options object,
161
+ `BrassInitOptions`. Its defaults suit most apps:
174
162
 
175
163
  | Init option | Default |
176
164
  | ----------- | ------- |
@@ -183,7 +171,7 @@ usually pass nothing:
183
171
 
184
172
  `Brass.init` resolves after one auth round-trip. Register `onError`
185
173
  and `onUserChange` BEFORE calling `init`, so that validation failures
186
- fire through your listener instead of dying as unhandled rejections; a
174
+ fire through your listener instead of dying as unhandled rejections. A
187
175
  bad `app_id` still resolves, with `Brass.user === null`.
188
176
 
189
177
  ### Ship it
@@ -198,9 +186,9 @@ npm run build
198
186
  npx @brass-build/cli publish ./dist
199
187
  ```
200
188
 
201
- The first run creates the app, enables hosting, uploads the build, and
202
- writes the app id to `.brass/project.json`. Commit that file so later
203
- publishes and your teammates target the same app.
189
+ The first publish creates the app, enables hosting, uploads the build,
190
+ and writes the app id to `.brass/project.json`. Commit that file so
191
+ later publishes and your teammates target the same app.
204
192
 
205
193
  Run `brass login` once first to authenticate (it opens your browser),
206
194
  or set `BRASS_SERVICE_TOKEN` in a pipeline. The CLI also pulls a
@@ -211,27 +199,23 @@ document's schema into your `brass-app.json`. See
211
199
 
212
200
  Auth has **four** states: `Brass.authStatus` is
213
201
  `'unknown' | 'signed-in' | 'signed-out' | 'guest'`, where `'unknown'`
214
- is the bootstrap window before `init` settles. The distinction matters
215
- because a page reload always passes through `'unknown'`, and during it
216
- `Brass.user` is still `null` even for a signed-in user. Never gate a
217
- sign-in CTA on `!Brass.user`: that flashes the sign-in screen at
218
- signed-in users on every reload. Render the CTA only on
219
- `'signed-out'`, and render your working surface for **both**
220
- `'signed-in'` and `'guest'`.
202
+ is the bootstrap window before `init` settles. A page reload always
203
+ passes through `'unknown'`, and during it `Brass.user` is still `null`
204
+ even for a signed-in user, so gating a sign-in CTA on `!Brass.user`
205
+ flashes the sign-in screen at signed-in users on every reload. Render
206
+ the CTA only on `'signed-out'`, and render your working surface for
207
+ **both** `'signed-in'` and `'guest'`.
221
208
 
222
209
  `'guest'` is the "anyone with the link" case: someone opened a public
223
210
  view link, so there is no signed-in user but `Brass.session` still
224
211
  opens the shared document **read-only** for them (`Brass.isGuest` is
225
212
  `true`). When the page URL carries the link token, the session boots
226
- the read-only document automatically; render your working surface for
227
- `'guest'` too (the document is read-only, so edits are already
228
- disabled).
213
+ that read-only document on its own.
229
214
 
230
215
  `Brass.onUserChange` is the single source for reacting to auth
231
216
  changes. It fires once when `init` resolves (with the real user or
232
217
  `null`), then on every sign-in and sign-out. A listener registered
233
- before `init` does not fire on registration; one registered after
234
- does.
218
+ before `init` does not fire on registration. One registered after does.
235
219
 
236
220
  The plain-DOM sample in the [quickstart](#quickstart) gets this
237
221
  right with no extra code, because both shells start `hidden` and
@@ -256,11 +240,9 @@ Brass.onUserChange(() => { authStatus.value = Brass.authStatus; });
256
240
  `Brass.mountToolbar(container)` fills a container with the full
257
241
  document toolbar, wired to the session: creating, opening, renaming,
258
242
  sharing, and deleting documents, undo / redo, an account menu, and a
259
- presence roster. Grow beyond it into your own header only when you need
260
- one, and even then compose
261
- the standard pieces (below) rather than rebuilding them. The call
262
- returns a `tools` slot where your app's own persistent controls (a mode
263
- palette, a save indicator) sit alongside the standard pieces:
243
+ presence roster. The call returns a `tools` slot where your app's own
244
+ persistent controls (a mode palette, a save indicator) sit alongside
245
+ the standard pieces:
264
246
 
265
247
  ```ts
266
248
  const bar = Brass.mountToolbar(document.getElementById('app-bar')!);
@@ -280,21 +262,21 @@ filling the rest:
280
262
  ```
281
263
 
282
264
  **Mount it once when the user signs in, not when a document opens.**
283
- New and Open live in its file menu, so the bar has to exist in the
284
- "signed in, nothing open" state; otherwise a user who just signed in
285
- has no way to open or create a document. It re-renders itself as
286
- documents open, switch, and close. It takes options `{ presence?, account? }` (both
287
- default to `true`), and the returned handle's `destroy()` removes it.
288
-
289
- A multi-pane app that builds its own header can still take the
290
- standard pieces (file menu, title, undo / redo, presence roster,
291
- drive-sync badge, account menu) individually instead of
292
- re-implementing them; see "Composable header pieces" in
293
- [`docs/custom-chrome.md`](./docs/custom-chrome.md). Include the
294
- drive-sync badge (`Brass.mountDriveSync(el)`): on a document linked to
295
- a connected drive it carries the user's pull, publish, and reconcile
296
- actions, and on any other document it renders nothing, so mount it
297
- unconditionally.
265
+ New and Open live in its file menu, so a user who has just signed in
266
+ reaches a first document only through a bar that is already there. It
267
+ re-renders itself as documents open, switch, and close. It takes
268
+ options `{ presence?, account?, drive? }` (all default to `true`), and
269
+ the returned handle's `destroy()` removes it.
270
+
271
+ A multi-pane app that builds its own header takes the standard pieces
272
+ (file menu, title, undo / redo, presence roster, drive-sync badge,
273
+ account menu) individually rather than re-implementing them. See
274
+ "Composable header pieces" in
275
+ [`docs/custom-chrome.md`](./docs/custom-chrome.md). Mount the
276
+ drive-sync badge (`Brass.mountDriveSync(el)`) unconditionally: on a
277
+ document linked to a connected drive it carries the user's pull,
278
+ publish, and reconcile actions, and on any other document it renders
279
+ nothing.
298
280
 
299
281
  ## Document lifecycle
300
282
 
@@ -317,9 +299,8 @@ Brass.session.subscribe((session) => {
317
299
 
318
300
  `session.subscribe` fires on lifecycle changes (open, close, switch,
319
301
  command state, sign-in / sign-out), and calls your callback with the
320
- `DocumentSession` synchronously on registration. It does **not** fire
321
- on document edits; those arrive through the document's own `subscribe`
322
- (below).
302
+ `DocumentSession` synchronously on registration. Document edits arrive
303
+ through the document's own `subscribe` (below), not here.
323
304
 
324
305
  `session.status` tells you which of six screens to render:
325
306
 
@@ -335,24 +316,22 @@ on document edits; those arrive through the document's own `subscribe`
335
316
  Gate edit affordances on `ready`: a canvas that accepts input in the
336
317
  `empty` state lets the user produce work with nowhere to save it.
337
318
 
338
- **When an open fails.** A link to a document that was deleted, or that
339
- the user cannot access, fails the open and the session enters `error`.
340
- Someone else deleting the document while the user is editing lands in
341
- the same state, so one screen covers both. Render the platform's
342
- message with your own recovery actions:
319
+ **When an open fails.** The session enters `error` when it cannot open a
320
+ document: someone deleted it, the user cannot access it, or Brass could
321
+ not prepare it (a file it could not read). Someone else deleting the
322
+ document while the user is editing lands in the same state, so one
323
+ screen covers them all. Render the platform's message with your own
324
+ recovery actions:
343
325
 
344
326
  ```ts
345
- if (session.status === 'error') {
327
+ if (session.error) {
346
328
  showMessage(session.error.message); // render verbatim; don't write your own
347
329
  // ...alongside your app's own New / Open buttons.
348
330
  }
349
331
  ```
350
332
 
351
- Display `session.error.message` verbatim; the platform authors it,
352
- like `session.loadingMessage` and `doc.status.message`. Supply the
353
- recovery actions yourself (your New / Open buttons). Use
354
- `session.error.status` for logging or your own handling. A failed open
355
- from an already-open document is different: the session keeps the
333
+ Use `session.error.status` for logging or your own handling. A failed
334
+ open from an already-open document is different: the session keeps the
356
335
  document you were editing and the `open()` call rejects, so catch that
357
336
  at the call site instead.
358
337
 
@@ -364,6 +343,14 @@ indeterminate). The document binds only after its content downloads,
364
343
  so the first `ready` render already has the real model to read
365
344
  directly.
366
345
 
346
+ **Work on an open document.** Brass prepares an imported file, syncs a
347
+ document to its drive, and does other work while the document is open.
348
+ `doc.status` carries that work and reads `null` when there is none;
349
+ `doc.onStatus(cb)` fires on every phase. Render `status.message`
350
+ verbatim and read `status.pending` for whether the work is still
351
+ running. The message is prose written for the reader, so show it rather
352
+ than parsing it or inferring a phase from it.
353
+
367
354
  **Where new documents land.** The `new` command (and
368
355
  `session.create()`) places the document for you: a sibling of the open
369
356
  document, or a platform-chosen home from the empty state.
@@ -393,14 +380,14 @@ document.addEventListener('keydown', (e) => {
393
380
  });
394
381
  ```
395
382
 
396
- Code that runs outside the render loop can await the bound document
397
- with `await Brass.session.whenReady()`; it resolves with the same
398
- instance as `session.document`, immediately if one is already bound,
399
- and rejects if the open fails or the user signs out before one binds.
383
+ Code that runs outside the render loop awaits the bound document with
384
+ `await Brass.session.whenReady()`. It resolves with the same instance
385
+ as `session.document`, immediately if one is already bound, and rejects
386
+ if the open fails or the user signs out before one binds.
400
387
 
401
- The hosted dialogs (`Brass.selectDocument()`, `Brass.shareDocument()`)
402
- handle browsing, picking, and sharing existing documents; the
403
- session's Open and Share commands invoke them for you.
388
+ The session's Open and Share commands invoke the hosted dialogs
389
+ (`Brass.selectDocument()`, `Brass.shareDocument()`) for you, so
390
+ browsing, picking, and sharing an existing document take no code.
404
391
 
405
392
  ## Editing documents
406
393
 
@@ -412,11 +399,16 @@ in [Document lifecycle](#document-lifecycle) does this in `bindEditor`,
412
399
  called whenever `s.document` changes:
413
400
 
414
401
  ```ts
402
+ import type { Document } from '@brass-build/client';
403
+
415
404
  function bindEditor(doc: Document): void {
416
405
  const walls = doc.map<Wall>('walls');
417
- // One subscribe line is the whole render loop; the first fire
406
+ // One subscribe block is the whole render loop; the first fire
418
407
  // carries the current state, so there is no separate initial render.
419
- const stop = walls.subscribe(() => renderWalls(walls.read()));
408
+ const stop = walls.subscribe((change) => {
409
+ for (const [id, wall] of change.set) reconcileWall(id, wall);
410
+ for (const id of change.deleted) removeWall(id);
411
+ });
420
412
  doc.onGone(() => stop());
421
413
  }
422
414
  ```
@@ -430,11 +422,18 @@ rebind and strand your editor on the previous state.
430
422
 
431
423
  The primary editing surface is a **typed container handle**:
432
424
  `doc.map<T>(key)` returns a scoped reference to one root container,
433
- and its `read`, `mutate`, and `subscribe` only see (and only pay for)
425
+ and its `subscribe`, `mutate`, and `get` only see (and only pay for)
434
426
  that container. A large document stays cheap because your editor
435
427
  touches the slice it cares about instead of walking the whole
436
428
  document.
437
429
 
430
+ **Make an item the smallest thing two people edit independently.** Two
431
+ people editing different items always keep both edits. Two editing the
432
+ same item keep one of the two, not a blend, so a grid where people edit
433
+ single cells is one item per cell rather than one per row. A small
434
+ collection merges more finely than a large one, so a small test is not
435
+ proof that your items are the right size.
436
+
438
437
  ```ts
439
438
  type Wall = { type: 'wall'; x: number; z: number };
440
439
 
@@ -444,7 +443,6 @@ walls.mutate((m) => { m[id] = { type: 'wall', x: 0, z: 0 }; }); // add
444
443
  walls.mutate((m) => { m[id].x = 42; }); // field edit
445
444
  walls.mutate((m) => { delete m[id]; }); // remove
446
445
 
447
- const all = walls.read(); // Record<string, Wall>
448
446
  const one = walls.get(id); // Wall | undefined
449
447
  ```
450
448
 
@@ -461,15 +459,19 @@ doc.list<Col>('cols').mutate((l) => {
461
459
  });
462
460
  ```
463
461
 
462
+ Reposition an entry with `l.move(from, to)`. Two people moving the same
463
+ entry at once converge on one entry, where a removal paired with an
464
+ insertion keeps both insertions and leaves the entry in the list twice.
465
+
464
466
  For a keyed collection that has to **stay in a stable order** (a list
465
467
  of notes, layers, or steps), use `doc.orderedMap<T>(mapKey, orderKey)`
466
468
  rather than a plain map with an app-computed sort key. It binds the map
467
469
  to a sibling order list and keeps them in lockstep: a new key appends in
468
470
  insertion order and a delete prunes from the order, both in the same
469
- commit as the map edit. There is no counter or index to derive a sort
470
- key from, which is what makes a hand-rolled key wrong here (a count is
471
- not stable across deletes, so a later entry can sort before an earlier
472
- one). Read it in order with `ordered()` or `keys()`.
471
+ commit as the map edit. A hand-rolled key goes wrong here because
472
+ nothing gives you a stable number to build one from: a count shifts as
473
+ entries are deleted, so a later entry can sort before an earlier one.
474
+ Read the collection in order with `ordered()` or `keys()`.
473
475
 
474
476
  ```ts
475
477
  const notes = doc.orderedMap<Note>('notes', 'note_order');
@@ -478,45 +480,65 @@ notes.mutate((m) => { m[id].text = 'hi'; }); // field edit; order untouche
478
480
  for (const { key, value } of notes.ordered()) renderRow(key, value);
479
481
  ```
480
482
 
483
+ `insert(key, value, at)` puts a new key somewhere other than the end,
484
+ and `move(key, to)` repositions one the collection already holds. Both
485
+ write the record and its place in the order in one commit, the way a
486
+ `mutate` add does.
487
+
488
+ ```ts
489
+ notes.insert(newId, { text: '' }, { after: caretId }); // Enter, mid-document
490
+ notes.move(dragged, { before: droppedOn }); // a drag landing
491
+ notes.move(dragged, { index: 0 }); // send to the top
492
+ ```
493
+
494
+ Name a neighbouring key where you have one. A key still anchors where
495
+ the reader meant after a peer adds or removes a row between your render
496
+ and your write, where an index by then counts different rows.
497
+ `{ index: n }` counts the positions `keys()` reports and leaves the key
498
+ at `n`, or last when `n` is past the end. Two readers inserting at one
499
+ spot both keep their record, and a reader who repositions a record
500
+ another deletes leaves the delete standing.
501
+
481
502
  A field edit leaves the order alone, so its `subscribe` fire carries
482
- only `set` / `deleted`; a change that moves the order also carries
503
+ only `set` / `deleted`. A change that moves the order also carries
483
504
  `change.order` (the live keys in order). Re-sequence your rows when
484
505
  `change.order` is present and patch in place otherwise, so a field echo
485
506
  never disturbs the row a user is editing.
486
507
 
487
- The doc-wide `doc.mutate(fn)` remains as a simpler fallback for tiny
488
- documents and one-liner integrations (a proxy over the default
489
- `entities` root). It operates on the same state as the handles, so an
490
- app can mix the two freely.
508
+ `doc.mutate(fn)` is the simpler surface for tiny documents and
509
+ one-liner integrations (a proxy over the default `entities` root). It
510
+ operates on the same state as the handles, so an app can mix the two.
491
511
 
492
- Two mutation rules:
512
+ Three mutation rules:
493
513
 
494
- - **A nested list read through a parent proxy is a detached
495
- snapshot**, so `m[id].tags.push(x)` does not persist. Assign a whole
496
- new array (`m[id].tags = [...m[id].tags, x]`), or take a handle for
497
- the list itself (`parent.list(key)`). Nested **maps** walked through
498
- the proxy are live; the snapshot rule applies to lists only.
514
+ - **Edit a nested list by assigning a new array, or through its own
515
+ handle.** `m[id].tags = [...m[id].tags, x]` persists wherever the
516
+ record is stored, and `parent.list(key)` gives a list you edit often a
517
+ handle of its own. Nested **maps** walked through the proxy are live.
499
518
  - **Don't write `undefined`.** Mutations JSON-serialise, so
500
519
  `undefined` inside an array becomes `null` and `undefined` property
501
520
  values are dropped. Use `null`, length checks, or `'key' in obj`
502
521
  instead.
522
+ - **Keys are yours except for the `brass:` prefix.** A map keeps its own
523
+ bookkeeping beside your records, under keys beginning with `brass:`,
524
+ so a write under one is refused. Any other key is free.
503
525
 
504
526
  A document starts **empty**, and the user's first action is their
505
527
  first undoable edit. Don't seed a baseline from the client: a faked
506
- "initial state" write is an ordinary edit, which means Ctrl-Z can
507
- delete it unexpectedly. Content the user asked for (their first add) is
508
- safe to persist; auto-written content they didn't ask for is not.
528
+ "initial state" write is an ordinary edit, so Ctrl-Z deletes it and the
529
+ user sees content vanish they never wrote. Persist what the user asked
530
+ for, and nothing else.
509
531
 
510
532
  ### Subscribing
511
533
 
512
534
  `handle.subscribe(cb)` is one channel for every change, whatever
513
- produced it: your own edit, a remote peer's, an undo, a version
514
- checkout. It fires once on registration with the whole container as
515
- `change.set`, then on every change, and each fire costs O(changed)
516
- regardless of document size. Because your own edits arrive here too,
517
- you render from one place and never patch a second copy at the edit
518
- site, where a field updates but a header showing the same value does
519
- not.
535
+ produced it: your own edit, a remote peer's, an undo, a move through
536
+ version history. It fires once on registration with the whole container as
537
+ `change.set`, then on every change, and each fire carries what that
538
+ change wrote, so editing one record costs one entry whatever the
539
+ document holds. Your own edits arrive here too, so render
540
+ from this one place: an app that also paints at the edit site updates
541
+ the field it touched and leaves a header showing the same value stale.
520
542
 
521
543
  ```ts
522
544
  const stop = walls.subscribe((change) => {
@@ -525,38 +547,94 @@ const stop = walls.subscribe((change) => {
525
547
  });
526
548
  ```
527
549
 
528
- **Reconcile, don't rebuild, and skip the focused input.** The input
529
- the user is typing in already shows what they typed, and a peer's
530
- conflicting value should resolve when they blur the field, not by
531
- yanking the caret mid-keystroke.
532
-
533
- In a declarative framework, your own edit echoes back through
534
- `subscribe` and re-renders the field mid-keystroke. Don't bind an editable input's value to a reactive
535
- expression derived from the document. Bind it to a **local edit
536
- buffer** (`v-model` on a per-field ref, `useState`), write the buffer
537
- through to the document on input, and copy the document's value back
538
- into the buffer only for fields that are not focused.
539
-
540
- **`subscribe` is deferred; `read` and `get` are not.** A fire lands on
541
- a microtask after the commit, so for the instant between your own
542
- `mutate` and its echo a value you cached from the previous fire is
543
- stale. Rendering never notices, because the echo repaints right after,
544
- but a **synchronous read-after-write** does: allocating the next id,
545
- checking a new name against the current set, or otherwise writing and
546
- then immediately reading the derived state. Read those from `read()` /
547
- `get()`, which always reflect the edit you just made, rather than from
548
- a projection you keep in sync only through `subscribe`. The failure
549
- mode is a mirror that you write through `mutate` and read back
550
- synchronously in the same turn: the read runs before the subscription
551
- that would have updated the mirror, so it sees the pre-write picture.
552
-
553
- A `list` handle has no per-key delta; its subscriber takes no
554
- arguments and re-reads (`list.subscribe(() => render(list.read()))`).
550
+ **Treat the document as the single source of truth.** Write each edit as
551
+ your user makes it, and draw what the document says. If you keep a local
552
+ model to render from, fill it from `subscribe`.
553
+
554
+ Assigning to `input.value` moves the caret, so skip a text input while it
555
+ has focus and read the document when focus leaves.
556
+
557
+ ```ts
558
+ input.addEventListener('input', () => {
559
+ cells.mutate((m) => { m[key] = input.value; });
560
+ });
561
+ input.addEventListener('blur', () => { input.value = cells.get(key) ?? ''; });
562
+
563
+ const stop = cells.subscribe((change) => {
564
+ for (const [key, value] of change.set) {
565
+ if (key === editingKey) continue; // the caret is in this one
566
+ paint(key, value);
567
+ }
568
+ });
569
+ ```
570
+
571
+ When two people edit one value, the document's merge picks the winner and
572
+ every client gets that same answer.
573
+
574
+ In a declarative framework, bind the input to a per-field ref (`v-model`,
575
+ `useState`) rather than to the document, and have your subscriber assign
576
+ that ref for every field except the focused one.
577
+
578
+ **A subscriber runs inside the `mutate` that caused it**, so what it
579
+ gave you is current the moment `mutate` returns. Answer a read straight
580
+ after a write (allocating the next id, checking a new name against the
581
+ ones in use) from what you already hold.
582
+
583
+ A `list` handle's subscriber receives splices, each carrying the
584
+ position an edit lands at, applied in the order they arrive:
585
+
586
+ ```ts
587
+ const stop = steps.subscribe((change) => {
588
+ for (const { at, removed, inserted } of change.splices) {
589
+ rows.splice(at, removed, ...inserted.map(renderStep));
590
+ }
591
+ });
592
+ ```
593
+
594
+ **A reorder arrives as a removal and an insertion, so pair them if the
595
+ item owns anything.** Rebuilding is correct and costs whatever the item
596
+ holds: a DOM node loses its focus, its selection and its scroll
597
+ position, and a view built on it starts over. As you apply a splice you
598
+ know which of your own items it removed, so match them against what the
599
+ same change inserts and move the one you already have.
600
+
601
+ ```ts
602
+ const stop = steps.subscribe((change) => {
603
+ const lifted = new Map<string, Row>();
604
+ for (const { at, removed, inserted } of change.splices) {
605
+ for (const row of rows.splice(at, removed)) lifted.set(row.id, row);
606
+ rows.splice(at, 0, ...inserted.map((step) => lifted.get(step.id) ?? renderStep(step)));
607
+ }
608
+ });
609
+ ```
610
+
555
611
  An `orderedMap` fire also carries `change.order` (the live ids in
556
612
  order) when the order moves. The optional second callback argument
557
- `origin` (`{ source: 'local' | 'remote' | 'undo' | 'redo' |
558
- 'checkout' }`) exists for attribution, such as highlighting a peer's
559
- edit; a plain render ignores it.
613
+ `origin` (`{ source: 'local' | 'remote' | 'undo' | 'redo' }`) carries
614
+ attribution, for a render that highlights a peer's edit. A move through
615
+ version history swaps the whole container rather than writing into it, so
616
+ it arrives with no `origin`; `doc.subscribe` reports that move, with the
617
+ version it landed on.
618
+
619
+ ### Knowing when you have the whole container
620
+
621
+ `handle.onSettled(cb)` fires when the container has been delivered
622
+ whole. Use it for work that belongs to the collection rather than to
623
+ any one change: framing a camera on what arrived, running a first pass
624
+ over it.
625
+
626
+ ```ts
627
+ walls.onSettled(() => fitViewTo(walls));
628
+ ```
629
+
630
+ It fires on registration, and again each time a version checkout, a
631
+ return to head or a hydrate replaces the container, so a reader who
632
+ moves through history gets the camera framed on the version they landed
633
+ on. Set a flag of your own on the first fire for work that should run
634
+ once.
635
+
636
+ It reports what this peer had converged to when the delivery began.
637
+ Later changes arrive through `subscribe`.
560
638
 
561
639
  `doc.subscribe(cb)` is the catch-all for events not tied to one
562
640
  container (saving-count ticks, branch-list changes). Use it for status
@@ -575,10 +653,8 @@ edits stay untouched. Outside the session, call `doc.undo()` /
575
653
 
576
654
  A **checkpoint** (`doc.createCheckpoint()`) is both the Ctrl-Z
577
655
  boundary and a marker on the document's version-history timeline, so
578
- checkpoint timing sets both granularities. Auto-seals fire 60 seconds after
579
- each burst of local edits settles, which means an app that does
580
- nothing still gets usable undo boundaries and a usable history pane.
581
- Tune it to your app's shape:
656
+ checkpoint timing sets both granularities. Auto-seals fire 60 seconds
657
+ after each burst of local edits settles. Tune that to your app's shape:
582
658
 
583
659
  | Your app shape | What to do | Result |
584
660
  | --------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
@@ -594,9 +670,16 @@ The SDK renders version preview itself: while an earlier version is
594
670
  showing, `mountToolbar` / `mountHeader` show the "viewing an earlier
595
671
  version" banner and return-to-live control (mount `mountVersionPreview`
596
672
  for a fully custom shell). You handle your own edit controls:
597
- `doc.readOnly` flips `true` during a preview (and for a view-only share
598
- link, and while a document is still loading), so gate your custom tools
599
- on it, and `doc.mutate` throws while read-only.
673
+ `doc.readOnly` flips `true` during a preview, for a reader whose access
674
+ to the document is view-only, for a view-only share link, and while a
675
+ document is loading or could not be loaded. Gate your custom tools on
676
+ it, and `doc.mutate` throws while read-only.
677
+
678
+ Read it inside your `doc.subscribe` handler rather than once when you
679
+ bind the editor. It also flips when someone withdraws the reader's
680
+ access while they have the document open, so re-reading is what
681
+ disables your tools at the moment writing stops working. `Brass.onError`
682
+ carries the message explaining what happened.
600
683
 
601
684
  ### Reacting to deletes
602
685
 
@@ -622,21 +705,19 @@ thumbnail on its dashboard card:
622
705
  Brass.session.useCanvas(() => document.querySelector('canvas'));
623
706
  ```
624
707
 
625
- The SDK debounces about a second after each local edit, screenshots
708
+ The SDK debounces 1.5s after each local edit, screenshots
626
709
  the canvas, downscales, and uploads. **WebGL canvases must set
627
710
  `preserveDrawingBuffer: true`** (Three.js:
628
- `new THREE.WebGLRenderer({ preserveDrawingBuffer: true })`); without
629
- it the read-back is a blank PNG, and the SDK warns once if the flag is
630
- missing. Non-canvas apps skip this entirely; a document without a
631
- thumbnail shows a blank tile and still gets its version-history
632
- timeline.
711
+ `new THREE.WebGLRenderer({ preserveDrawingBuffer: true })`). Without
712
+ it the read-back is a blank PNG, and the SDK warns once about the
713
+ missing flag.
633
714
 
634
715
  ## Presence (live cursors and who's here)
635
716
 
636
717
  `doc.presence` shows who else is in the document and what they are
637
718
  doing: a roster of the other live participants plus a small per-user
638
719
  `state` (cursor, camera, selection). It is non-null on any open
639
- writable document, and it is fully ephemeral; nothing survives a
720
+ writable document, and it is fully ephemeral: nothing survives a
640
721
  disconnect. `Brass.session.presence` is the same client for the
641
722
  session's open document, so use whichever handle you have.
642
723
 
@@ -655,11 +736,14 @@ canvas.addEventListener('pointermove', (e) => {
655
736
 
656
737
  `cursor` is a 3D point in document world space (meters, right-handed,
657
738
  Z-up; a 2D app passes `z: 0`), and an entity or text editor publishes
658
- `selection` instead. `Brass.mountToolbar` and
659
- `Brass.mountPresenceRoster` render the header roster for you. To
660
- render remote cursors, diff the roster by `connectionId` on the
661
- `change` event; the full recipe, coordinate conventions, and types are
662
- in [`docs/presence.md`](./docs/presence.md).
739
+ `selection` instead. A `selection` grows with what the user picked, so
740
+ publish a bounded summary (the first few ids plus a count, or the
741
+ selection's bounding box) once a selection can get large, and subscribe
742
+ with `presence.onStatus` to catch a layer Brass withholds as oversized.
743
+ `Brass.mountToolbar` and `Brass.mountPresenceRoster` render the header
744
+ roster for you. To render remote cursors, diff the roster by
745
+ `connectionId` on the `change` event. The full recipe, coordinate
746
+ conventions, and types are in [`docs/presence.md`](./docs/presence.md).
663
747
 
664
748
  ## Streams (partitioning large documents)
665
749
 
@@ -676,23 +760,28 @@ records.mutate((m) => {
676
760
  });
677
761
  ```
678
762
 
679
- `Brass.openDocumentById(docId, { stream })` opens the document with that
680
- stream as its own state, so an app that works in one part of a large
681
- document downloads only that part; it defaults to `'default'`, the stream
682
- every document has. `doc.getOrCreateStream(name)` opens the document's
683
- stream of that name and makes it when the document does not hold it yet
684
- (idempotent, and concurrent callers converge on one stream, so an app
685
- reaches for the stream it expects on every open); `doc.stream(name)`
686
- opens one the document already holds and rejects any other name, and
687
- resolves the name the document was opened at back to the document itself;
688
- `doc.listStreams()` lists them. Code that iterates stream names
689
- therefore handles partitioned and unpartitioned documents with one path.
763
+ Which call you reach for depends on what you already know about the
764
+ document:
765
+
766
+ - `Brass.openDocumentById(docId, { stream })` opens the document with
767
+ that stream as its own state, so an app that works in one part of a
768
+ large document downloads only that part. It defaults to `'default'`,
769
+ the stream every document has.
770
+ - `doc.getOrCreateStream(name)` opens the document's stream of that name
771
+ and makes it when the document does not hold it yet. It is idempotent,
772
+ and concurrent callers converge on one stream, so your app reaches for
773
+ the stream it expects on every open.
774
+ - `doc.stream(name)` opens one the document already holds and rejects
775
+ any other name. Ask it for the stream you opened the document at and
776
+ you get the document back, so one loop over `doc.listStreams()` reads
777
+ a partitioned and an unpartitioned document alike.
778
+
690
779
  A stream costs one extra round trip, so add one when it carries real
691
780
  content: partition by load unit (a discipline, a heavy layer), not by
692
781
  concept.
693
782
 
694
- A schema names the streams it expects under `streams`. An app that
695
- follows references between records reads every stream the document holds
783
+ A schema names the streams it expects under `streams`. If your app
784
+ follows references between records, read every stream the document holds
696
785
  (`doc.listStreams()`). A document can hold streams your schema does not
697
786
  name, and your app leaves those alone. Naming a stream is how records
698
787
  you work with (clash findings, review notes, a cache) ride in the
@@ -703,15 +792,14 @@ out of the exported file. See [`docs/streams.md`](./docs/streams.md) and
703
792
 
704
793
  ## Imported files
705
794
 
706
- Start from what your app will open: the file the app exists for,
707
- whether it is your own or an example from a user. Add it to an
708
- organization (the dashboard, or any app's hosted picker, brings a file
709
- in) and read what the document it becomes holds; that document decides
710
- the schema your app declares. Your app opens it the way every document
711
- opens: through the session and the hosted picker.
795
+ Importers convert standard file types into Brass documents. Your app
796
+ opens one through the session and the hosted picker like any other
797
+ document. The schema its records follow is what lets a second app open
798
+ the same file.
712
799
 
713
- A document imported from a file (an upload or a drive link) lands in
714
- one of two shapes, and the document's own page tells you which:
800
+ What you build depends on whether an importer for the format already
801
+ exists. Open a document of that type in the dashboard to see which case
802
+ you are in:
715
803
 
716
804
  - **It holds records under a schema.** An importer for the format
717
805
  already exists (an installed app provides it). Adopt the document's
@@ -724,10 +812,11 @@ one of two shapes, and the document's own page tells you which:
724
812
  follows references between records, read every stream the document
725
813
  holds, see
726
814
  [streams](./docs/streams.md#reading-a-document-spread-across-streams).
727
- - **It is the stored file, and no schema resolves.** Nothing imports
728
- the format yet, so your app is its first importer. Register both
729
- halves of the format at boot: the importer parses the stored file
730
- into records, the serializer reconstitutes the file from them.
815
+ - **It holds the stored file under no schema.** Nothing imports the
816
+ format yet, so your app is its first importer, and your importer is
817
+ what makes files of that format openable. Register both halves at
818
+ boot: the importer parses the stored file into records, the serializer
819
+ reconstitutes the file from them.
731
820
 
732
821
  ```ts
733
822
  Brass.registerImporter(async (src, doc) => {
@@ -746,43 +835,39 @@ one of two shapes, and the document's own page tells you which:
746
835
  `brass-app.json`; importing publishes that schema and stamps it on
747
836
  the document, so the next app can adopt it like any imported
748
837
  document's. Declare the file match there too
749
- (`"accepts": [{ "extension": "gpx" }]`) so Brass offers your app when
750
- someone uploads one.
838
+ (`"imports": [{ "extension": "gpx" }]`) so Brass runs your importer on
839
+ files of that type.
751
840
 
752
841
  To make your app open documents of an existing file type, adopt that
753
842
  type's schema rather than re-derive its record shape. Get a document of
754
- the shape you target (upload a sample file to your organization, or
755
- open a document another app produced), copy
756
- its schema from the document's page in the dashboard (**Download
757
- brass-app.json**, or `npx @brass-build/cli schema pull --doc <docId>`),
758
- and keep it verbatim as `schema` in your `brass-app.json`. The schema's family folds
759
- into your `opens`, so documents of that shape route to your app, and
760
- building against the copied body keeps you compatible with the other
761
- apps that edit them. The declared schema is what routes existing
762
- documents to your app: the picker lists documents by matching your
763
- declared schema (or `accepts`), so ship the manifest with your first
764
- deploy.
843
+ the shape you target: upload a sample file to your organization, or open
844
+ one that another app produced. Copy its schema from the document's page
845
+ in the dashboard (**Download brass-app.json**, or
846
+ `npx @brass-build/cli schema pull --doc <docId>`), keep it verbatim as
847
+ `schema` in your `brass-app.json`, and ship that manifest with your
848
+ first deploy. The picker lists documents by matching your declared
849
+ schema, and its family folds into your `opens`, so documents of that
850
+ shape route to your app and your code stays compatible with the other
851
+ apps that edit them.
765
852
 
766
853
  Every app ships a capability manifest at `/.well-known/brass-app.json`
767
854
  in its bundle root, next to `index.html`. It declares the schema your
768
- app works with, the files it accepts, the format it exports, and the
855
+ app works with, the files it imports, the format it exports, and the
769
856
  icon the platform renders as your app's mark. The platform re-reads it
770
- after each deploy, so publishing your bundle is what registers it, and
771
- an app that does not serve one registers nothing.
857
+ after each deploy, so publishing your bundle is what registers it.
772
858
  [`docs/app-manifest.md`](./docs/app-manifest.md) covers where the file
773
859
  goes, what each key does, and how to confirm your build carries it.
774
860
 
775
- The record-store shape, the conversion recipe, and schemas are covered
776
- in [`docs/imported-documents.md`](./docs/imported-documents.md) and
777
- [`docs/schemas.md`](./docs/schemas.md).
861
+ [`docs/imported-documents.md`](./docs/imported-documents.md) covers the
862
+ record store an imported file becomes, and
863
+ [`docs/schemas.md`](./docs/schemas.md) covers declaring its schema.
778
864
 
779
865
  ## Reference
780
866
 
781
- `Brass` is the top-level namespace your app calls; its members are
782
- below. The package's TypeScript types document `Document` and the
783
- container handles (your editor's autocomplete lists every member and
784
- signature), and the examples above show them in use, so this reference
785
- does not repeat them.
867
+ `Brass` is the top-level namespace your app calls. The package's
868
+ TypeScript types document `Document` and the container handles, so your
869
+ editor's autocomplete lists every member and signature, and the examples
870
+ above show them in use.
786
871
 
787
872
  ### `Brass`
788
873
 
@@ -805,7 +890,7 @@ does not repeat them.
805
890
  | `Brass.shareDocument(docId)` | `Promise<void>` |
806
891
  | `Brass.registerImporter(fn)` | `void` (see `docs/imported-documents.md`) |
807
892
  | `Brass.registerSerializer(fn)` | `void` (see `docs/imported-documents.md`) |
808
- | `Brass.documents` | namespace getter (metadata read, thumbnails, branches for a known doc id) |
893
+ | `Brass.documents` | namespace getter (metadata read and thumbnails for a known doc id) |
809
894
 
810
895
  The composable header mounts (`mountHeader`, `mountFileMenu`,
811
896
  `mountDocumentTitle`, `mountUndoRedo`, `mountPresenceRoster`,
@@ -813,8 +898,8 @@ The composable header mounts (`mountHeader`, `mountFileMenu`,
813
898
  [`docs/custom-chrome.md`](./docs/custom-chrome.md).
814
899
 
815
900
  `OpenedDocument = Document & { readonly id: string }`. Open / create
816
- methods return `OpenedDocument`; type app-level variables as
817
- `OpenedDocument` so `doc.id` stays non-null. `BrassUser` is
901
+ methods return `OpenedDocument`, so type app-level variables as
902
+ `OpenedDocument` and `doc.id` stays non-null. `BrassUser` is
818
903
  `{ sub: string; email?: string; display_name?: string; avatar_url?: string }`.
819
904
 
820
905
  ### Errors
@@ -823,7 +908,7 @@ Every call rejects with one error type, `BrassError`. HTTP calls carry
823
908
  `.status: number`, and the hosted-UI launchers (`selectDocument`,
824
909
  `shareDocument`, ...) carry `.code` (`'embed_error'`,
825
910
  `'not_in_browser'`). A call made without a signed-in session rejects
826
- synchronously with `.status === 401`.
911
+ with `.status === 401` before it reaches the network.
827
912
 
828
913
  ## Guides
829
914
 
@@ -831,6 +916,7 @@ synchronously with `.status === 401`.
831
916
  - [`docs/app-manifest.md`](./docs/app-manifest.md): the `brass-app.json` capability manifest every app ships, where it goes in your bundle, and what each key declares.
832
917
  - [`docs/imported-documents.md`](./docs/imported-documents.md): the record store an imported file becomes, `Brass.registerImporter`, `doc.sourceBlob()`, and `Brass.registerSerializer`.
833
918
  - [`docs/schemas.md`](./docs/schemas.md): declaring a schema, creating a stream, what lets an app open a document, and what breaks that.
919
+ - [`docs/streams.md`](./docs/streams.md): partitioning a document for lazy loading, per-stream undo, and reading a document spread across streams.
834
920
  - [`docs/custom-chrome.md`](./docs/custom-chrome.md): composable header pieces, and rendering `session.commands` yourself for a fully custom shell.
835
921
  - [`docs/presence.md`](./docs/presence.md): remote cursors, roster rendering, coordinate conventions, full types.
836
922
  - [`docs/hosted-ui-advanced.md`](./docs/hosted-ui-advanced.md): the hosted launchers (`Brass.selectDocument`, `Brass.shareDocument`), their redirect behavior in browsers that block the iframe, theming, CSP.