@brass-build/client 0.4.0 → 0.5.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 (327) hide show
  1. package/AGENTS.md +142 -41
  2. package/CHANGELOG.md +93 -4
  3. package/README.md +666 -179
  4. package/dist/acting-organization.d.ts.map +1 -1
  5. package/dist/acting-organization.js +6 -11
  6. package/dist/acting-organization.js.map +1 -1
  7. package/dist/api-fetch.d.ts +8 -0
  8. package/dist/api-fetch.d.ts.map +1 -1
  9. package/dist/api-fetch.js +35 -8
  10. package/dist/api-fetch.js.map +1 -1
  11. package/dist/batched-get.d.ts +11 -0
  12. package/dist/batched-get.d.ts.map +1 -0
  13. package/dist/batched-get.js +92 -0
  14. package/dist/batched-get.js.map +1 -0
  15. package/dist/call-service.d.ts +4 -0
  16. package/dist/call-service.d.ts.map +1 -1
  17. package/dist/call-service.js +66 -3
  18. package/dist/call-service.js.map +1 -1
  19. package/dist/client.d.ts +51 -2
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +982 -124
  22. package/dist/client.js.map +1 -1
  23. package/dist/document-checkpoints.d.ts +25 -2
  24. package/dist/document-checkpoints.d.ts.map +1 -1
  25. package/dist/document-checkpoints.js +258 -61
  26. package/dist/document-checkpoints.js.map +1 -1
  27. package/dist/document-commands.d.ts.map +1 -1
  28. package/dist/document-commands.js +5 -3
  29. package/dist/document-commands.js.map +1 -1
  30. package/dist/document-contents.d.ts +10 -0
  31. package/dist/document-contents.d.ts.map +1 -1
  32. package/dist/document-contents.js +23 -1
  33. package/dist/document-contents.js.map +1 -1
  34. package/dist/document-set-subscription.d.ts +56 -0
  35. package/dist/document-set-subscription.d.ts.map +1 -0
  36. package/dist/document-set-subscription.js +240 -0
  37. package/dist/document-set-subscription.js.map +1 -0
  38. package/dist/document-signals.d.ts +6 -0
  39. package/dist/document-signals.d.ts.map +1 -1
  40. package/dist/document-signals.js +31 -1
  41. package/dist/document-signals.js.map +1 -1
  42. package/dist/document-streams.d.ts +36 -2
  43. package/dist/document-streams.d.ts.map +1 -1
  44. package/dist/document-streams.js +69 -18
  45. package/dist/document-streams.js.map +1 -1
  46. package/dist/document-thumbnail-capture.d.ts +12 -2
  47. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  48. package/dist/document-thumbnail-capture.js +103 -27
  49. package/dist/document-thumbnail-capture.js.map +1 -1
  50. package/dist/documents-namespace.d.ts +92 -10
  51. package/dist/documents-namespace.d.ts.map +1 -1
  52. package/dist/documents-namespace.js +285 -39
  53. package/dist/documents-namespace.js.map +1 -1
  54. package/dist/documents.d.ts +173 -8
  55. package/dist/documents.d.ts.map +1 -1
  56. package/dist/documents.js +1778 -310
  57. package/dist/documents.js.map +1 -1
  58. package/dist/errors.d.ts +3 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js.map +1 -1
  61. package/dist/fetch-retry.d.ts +1 -0
  62. package/dist/fetch-retry.d.ts.map +1 -1
  63. package/dist/fetch-retry.js +2 -0
  64. package/dist/fetch-retry.js.map +1 -1
  65. package/dist/file-choice.d.ts +2 -0
  66. package/dist/file-choice.d.ts.map +1 -0
  67. package/dist/file-choice.js +27 -0
  68. package/dist/file-choice.js.map +1 -0
  69. package/dist/guest-link.d.ts +18 -1
  70. package/dist/guest-link.d.ts.map +1 -1
  71. package/dist/guest-link.js +329 -1
  72. package/dist/guest-link.js.map +1 -1
  73. package/dist/handle-types.d.ts +67 -5
  74. package/dist/handle-types.d.ts.map +1 -1
  75. package/dist/handles.d.ts +15 -19
  76. package/dist/handles.d.ts.map +1 -1
  77. package/dist/handles.js +231 -76
  78. package/dist/handles.js.map +1 -1
  79. package/dist/hosted-ui.d.ts +61 -0
  80. package/dist/hosted-ui.d.ts.map +1 -1
  81. package/dist/hosted-ui.js +94 -42
  82. package/dist/hosted-ui.js.map +1 -1
  83. package/dist/import-contract.d.ts +74 -0
  84. package/dist/import-contract.d.ts.map +1 -0
  85. package/dist/import-contract.js +163 -0
  86. package/dist/import-contract.js.map +1 -0
  87. package/dist/import-guest.d.ts +9 -0
  88. package/dist/import-guest.d.ts.map +1 -0
  89. package/dist/import-guest.js +155 -0
  90. package/dist/import-guest.js.map +1 -0
  91. package/dist/import-report.d.ts +17 -0
  92. package/dist/import-report.d.ts.map +1 -0
  93. package/dist/import-report.js +7 -0
  94. package/dist/import-report.js.map +1 -0
  95. package/dist/importer-wait.d.ts +2 -0
  96. package/dist/importer-wait.d.ts.map +1 -0
  97. package/dist/importer-wait.js +5 -0
  98. package/dist/importer-wait.js.map +1 -0
  99. package/dist/importer.d.ts +18 -4
  100. package/dist/importer.d.ts.map +1 -1
  101. package/dist/importer.js +322 -105
  102. package/dist/importer.js.map +1 -1
  103. package/dist/in-development.d.ts +2 -0
  104. package/dist/in-development.d.ts.map +1 -0
  105. package/dist/in-development.js +10 -0
  106. package/dist/in-development.js.map +1 -0
  107. package/dist/index.d.ts +16 -8
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +24 -8
  110. package/dist/index.js.map +1 -1
  111. package/dist/init-keys.d.ts +1 -1
  112. package/dist/init-keys.d.ts.map +1 -1
  113. package/dist/init-keys.js +6 -1
  114. package/dist/init-keys.js.map +1 -1
  115. package/dist/input-documents.d.ts +67 -0
  116. package/dist/input-documents.d.ts.map +1 -0
  117. package/dist/input-documents.js +693 -0
  118. package/dist/input-documents.js.map +1 -0
  119. package/dist/inputs-status.d.ts +80 -0
  120. package/dist/inputs-status.d.ts.map +1 -0
  121. package/dist/inputs-status.js +460 -0
  122. package/dist/inputs-status.js.map +1 -0
  123. package/dist/inputs.d.ts +2 -0
  124. package/dist/inputs.d.ts.map +1 -0
  125. package/dist/inputs.js +793 -0
  126. package/dist/inputs.js.map +1 -0
  127. package/dist/internal.d.ts +6 -1
  128. package/dist/internal.d.ts.map +1 -1
  129. package/dist/internal.js +4 -1
  130. package/dist/internal.js.map +1 -1
  131. package/dist/join-content.d.ts +33 -0
  132. package/dist/join-content.d.ts.map +1 -0
  133. package/dist/join-content.js +114 -0
  134. package/dist/join-content.js.map +1 -0
  135. package/dist/join-stamp.d.ts +3 -0
  136. package/dist/join-stamp.d.ts.map +1 -0
  137. package/dist/join-stamp.js +12 -0
  138. package/dist/join-stamp.js.map +1 -0
  139. package/dist/manifest-drift.d.ts +9 -0
  140. package/dist/manifest-drift.d.ts.map +1 -0
  141. package/dist/manifest-drift.js +46 -0
  142. package/dist/manifest-drift.js.map +1 -0
  143. package/dist/open-document.d.ts +109 -2
  144. package/dist/open-document.d.ts.map +1 -1
  145. package/dist/open-document.js +1822 -811
  146. package/dist/open-document.js.map +1 -1
  147. package/dist/page-restore.d.ts +12 -0
  148. package/dist/page-restore.d.ts.map +1 -0
  149. package/dist/page-restore.js +23 -0
  150. package/dist/page-restore.js.map +1 -0
  151. package/dist/placement.d.ts +13 -0
  152. package/dist/placement.d.ts.map +1 -0
  153. package/dist/placement.js +34 -0
  154. package/dist/placement.js.map +1 -0
  155. package/dist/presence.d.ts +1 -0
  156. package/dist/presence.d.ts.map +1 -1
  157. package/dist/presence.js +16 -4
  158. package/dist/presence.js.map +1 -1
  159. package/dist/read-only-document.d.ts +87 -0
  160. package/dist/read-only-document.d.ts.map +1 -0
  161. package/dist/read-only-document.js +4 -0
  162. package/dist/read-only-document.js.map +1 -0
  163. package/dist/record-paging.d.ts +63 -6
  164. package/dist/record-paging.d.ts.map +1 -1
  165. package/dist/record-paging.js +122 -29
  166. package/dist/record-paging.js.map +1 -1
  167. package/dist/references.d.ts +40 -0
  168. package/dist/references.d.ts.map +1 -0
  169. package/dist/references.js +135 -0
  170. package/dist/references.js.map +1 -0
  171. package/dist/request-lane.d.ts +5 -0
  172. package/dist/request-lane.d.ts.map +1 -0
  173. package/dist/request-lane.js +48 -0
  174. package/dist/request-lane.js.map +1 -0
  175. package/dist/sealed-arrow.d.ts +170 -0
  176. package/dist/sealed-arrow.d.ts.map +1 -0
  177. package/dist/sealed-arrow.js +769 -0
  178. package/dist/sealed-arrow.js.map +1 -0
  179. package/dist/sealed-cache.d.ts +21 -0
  180. package/dist/sealed-cache.d.ts.map +1 -0
  181. package/dist/sealed-cache.js +247 -0
  182. package/dist/sealed-cache.js.map +1 -0
  183. package/dist/sealed-encode.d.ts +87 -0
  184. package/dist/sealed-encode.d.ts.map +1 -0
  185. package/dist/sealed-encode.js +876 -0
  186. package/dist/sealed-encode.js.map +1 -0
  187. package/dist/sealed-handles.d.ts +45 -0
  188. package/dist/sealed-handles.d.ts.map +1 -0
  189. package/dist/sealed-handles.js +408 -0
  190. package/dist/sealed-handles.js.map +1 -0
  191. package/dist/sealed-store.d.ts +242 -0
  192. package/dist/sealed-store.d.ts.map +1 -0
  193. package/dist/sealed-store.js +1155 -0
  194. package/dist/sealed-store.js.map +1 -0
  195. package/dist/serializer.d.ts +6 -15
  196. package/dist/serializer.d.ts.map +1 -1
  197. package/dist/session-toolbar.d.ts +20 -5
  198. package/dist/session-toolbar.d.ts.map +1 -1
  199. package/dist/session-toolbar.js +419 -67
  200. package/dist/session-toolbar.js.map +1 -1
  201. package/dist/session.d.ts +38 -1
  202. package/dist/session.d.ts.map +1 -1
  203. package/dist/session.js +562 -79
  204. package/dist/session.js.map +1 -1
  205. package/dist/sha256.d.ts +3 -0
  206. package/dist/sha256.d.ts.map +1 -0
  207. package/dist/sha256.js +74 -0
  208. package/dist/sha256.js.map +1 -0
  209. package/dist/stable-handles.d.ts +19 -0
  210. package/dist/stable-handles.d.ts.map +1 -0
  211. package/dist/stable-handles.js +193 -0
  212. package/dist/stable-handles.js.map +1 -0
  213. package/dist/staged-seal.d.ts +2 -0
  214. package/dist/staged-seal.d.ts.map +1 -0
  215. package/dist/staged-seal.js +76 -0
  216. package/dist/staged-seal.js.map +1 -0
  217. package/dist/subscriber-delivery.d.ts +8 -0
  218. package/dist/subscriber-delivery.d.ts.map +1 -0
  219. package/dist/subscriber-delivery.js +31 -0
  220. package/dist/subscriber-delivery.js.map +1 -0
  221. package/dist/tail-op-import.d.ts +10 -0
  222. package/dist/tail-op-import.d.ts.map +1 -0
  223. package/dist/tail-op-import.js +33 -0
  224. package/dist/tail-op-import.js.map +1 -0
  225. package/dist/topic-lifecycle.d.ts +6 -0
  226. package/dist/topic-lifecycle.d.ts.map +1 -1
  227. package/dist/topic-lifecycle.js +32 -2
  228. package/dist/topic-lifecycle.js.map +1 -1
  229. package/dist/trial-replace-dialog.d.ts +10 -0
  230. package/dist/trial-replace-dialog.d.ts.map +1 -0
  231. package/dist/trial-replace-dialog.js +153 -0
  232. package/dist/trial-replace-dialog.js.map +1 -0
  233. package/dist/types.d.ts +8 -1
  234. package/dist/types.d.ts.map +1 -1
  235. package/dist/undo.d.ts +60 -5
  236. package/dist/undo.d.ts.map +1 -1
  237. package/dist/undo.js +344 -75
  238. package/dist/undo.js.map +1 -1
  239. package/dist/version-bar.d.ts +1 -0
  240. package/dist/version-bar.d.ts.map +1 -1
  241. package/dist/version-bar.js +2 -1
  242. package/dist/version-bar.js.map +1 -1
  243. package/dist/version.d.ts +2 -2
  244. package/dist/version.js +1 -1
  245. package/dist/ws-client.d.ts +21 -0
  246. package/dist/ws-client.d.ts.map +1 -1
  247. package/dist/ws-client.js +192 -8
  248. package/dist/ws-client.js.map +1 -1
  249. package/dist/ws-frame-limit.d.ts +1 -0
  250. package/dist/ws-frame-limit.d.ts.map +1 -1
  251. package/dist/ws-frame-limit.js.map +1 -1
  252. package/docs/app-manifest.md +121 -17
  253. package/docs/custom-chrome.md +24 -16
  254. package/docs/hosted-ui-advanced.md +38 -6
  255. package/docs/imported-documents.md +104 -51
  256. package/docs/presence.md +16 -4
  257. package/docs/schemas.md +8 -5
  258. package/docs/services.md +6 -6
  259. package/docs/streams.md +139 -150
  260. package/docs/text-fields.md +26 -19
  261. package/package.json +5 -2
  262. package/src/acting-organization.ts +6 -12
  263. package/src/api-fetch.ts +53 -3
  264. package/src/auth-flow.ts +1 -1
  265. package/src/batched-get.ts +117 -0
  266. package/src/call-service.ts +78 -3
  267. package/src/client.ts +1092 -121
  268. package/src/document-checkpoints.ts +307 -65
  269. package/src/document-commands.ts +5 -5
  270. package/src/document-contents.ts +27 -1
  271. package/src/document-set-subscription.ts +274 -0
  272. package/src/document-signals.ts +47 -5
  273. package/src/document-streams.ts +85 -16
  274. package/src/document-thumbnail-capture.ts +118 -32
  275. package/src/documents-namespace.ts +539 -84
  276. package/src/documents.ts +2017 -348
  277. package/src/errors.ts +17 -6
  278. package/src/fetch-retry.ts +5 -0
  279. package/src/file-choice.ts +26 -0
  280. package/src/guest-link.ts +362 -1
  281. package/src/handle-types.ts +73 -47
  282. package/src/handles.ts +253 -83
  283. package/src/hosted-ui.ts +211 -50
  284. package/src/import-contract.ts +227 -0
  285. package/src/import-guest.ts +193 -0
  286. package/src/import-report.ts +24 -0
  287. package/src/importer-wait.ts +4 -0
  288. package/src/importer.ts +386 -131
  289. package/src/in-development.ts +10 -0
  290. package/src/index.ts +76 -16
  291. package/src/init-keys.ts +6 -1
  292. package/src/input-documents.ts +819 -0
  293. package/src/inputs-status.ts +598 -0
  294. package/src/inputs.ts +1090 -0
  295. package/src/internal.ts +7 -0
  296. package/src/join-content.ts +135 -0
  297. package/src/join-stamp.ts +12 -0
  298. package/src/manifest-drift.ts +54 -0
  299. package/src/open-document.ts +2346 -930
  300. package/src/page-restore.ts +38 -0
  301. package/src/placement.ts +54 -0
  302. package/src/presence.ts +21 -5
  303. package/src/read-only-document.ts +133 -0
  304. package/src/record-paging.ts +147 -25
  305. package/src/references.ts +207 -0
  306. package/src/request-lane.ts +59 -0
  307. package/src/sealed-arrow.ts +843 -0
  308. package/src/sealed-cache.ts +267 -0
  309. package/src/sealed-encode.ts +1015 -0
  310. package/src/sealed-handles.ts +486 -0
  311. package/src/sealed-store.ts +1330 -0
  312. package/src/serializer.ts +8 -20
  313. package/src/session-toolbar.ts +463 -72
  314. package/src/session.ts +740 -99
  315. package/src/sha256.ts +65 -0
  316. package/src/stable-handles.ts +235 -0
  317. package/src/staged-seal.ts +90 -0
  318. package/src/subscriber-delivery.ts +37 -0
  319. package/src/tail-op-import.ts +30 -0
  320. package/src/topic-lifecycle.ts +38 -2
  321. package/src/trial-replace-dialog.ts +163 -0
  322. package/src/types.ts +6 -6
  323. package/src/undo.ts +386 -73
  324. package/src/version-bar.ts +5 -1
  325. package/src/version.ts +1 -1
  326. package/src/ws-client.ts +208 -16
  327. package/src/ws-frame-limit.ts +1 -0
package/README.md CHANGED
@@ -28,8 +28,9 @@ npm install @brass-build/client
28
28
  - [Document lifecycle](#document-lifecycle)
29
29
  - [Editing documents](#editing-documents)
30
30
  - [Presence](#presence-live-cursors-and-whos-here)
31
- - [Streams](#streams-partitioning-large-documents)
31
+ - [Streams](#streams-loading-a-document-in-parts)
32
32
  - [Imported files](#imported-files)
33
+ - [Referencing other documents](#referencing-other-documents)
33
34
  - [Calling an outside service](#calling-an-outside-service)
34
35
  - [Reference](#reference)
35
36
  - [Guides](#guides)
@@ -46,7 +47,7 @@ integration.
46
47
  Build a shared checklist on @brass-build/client (NPM).
47
48
  ```
48
49
 
49
- **Or wire it by hand.** Six calls, and your own code around them:
50
+ **Or wire it by hand:**
50
51
 
51
52
  ```ts
52
53
  import { Brass } from '@brass-build/client';
@@ -61,8 +62,7 @@ Brass.onError((err) => console.error('[Brass]', err.message));
61
62
  Brass.onUserChange(renderAuthState);
62
63
  signInButton.onclick = () => Brass.signIn();
63
64
 
64
- // The whole file menu in one call: document title, undo / redo,
65
- // account menu, presence roster.
65
+ // Document title, file menu, undo / redo, account menu, presence roster.
66
66
  Brass.mountToolbar(appBar);
67
67
 
68
68
  // Rebind your editor to whichever document the session opens. Compare
@@ -89,8 +89,8 @@ Then build and publish:
89
89
 
90
90
  ```sh
91
91
  npm run build
92
- npx @brass-build/cli login # opens your browser to sign in
93
- npx @brass-build/cli publish ./dist
92
+ npx @brass-build/cli login # sign in
93
+ npx @brass-build/cli publish ./dist --name "My app"
94
94
  ```
95
95
 
96
96
  The first publish creates the app, hosts it at
@@ -102,8 +102,9 @@ app id (the `brass_app_...` above).
102
102
  To take the [quickstart](#quickstart) from source to a
103
103
  signed-in, hosted app:
104
104
 
105
- 1. **Ship it.** `npx @brass-build/cli publish ./dist` creates the app,
106
- hosts it, and returns its sign-in URL. See [Ship it](#ship-it).
105
+ 1. **Ship it.** `npx @brass-build/cli publish ./dist --name "My app"`
106
+ creates the app, hosts it, and returns its sign-in URL. See
107
+ [Ship it](#ship-it).
107
108
  2. **Pass an app id unless Brass hosts your bundle.** A published bundle
108
109
  resolves its id from the slot it is served on, so it calls
109
110
  `Brass.init()` with no argument. Pass the id everywhere else, in local
@@ -116,12 +117,13 @@ signed-in, hosted app:
116
117
  (`Brass.init(import.meta.env.VITE_BRASS_APP_ID)`) and Brass falls back
117
118
  to the serving slot. Set the variable for local development as well:
118
119
  `localhost` has no slot to fall back to, and an absent id fails init.
119
- 3. **Allow your development origin.** An app you create in the dashboard
120
- already allows sign-in from `localhost` / `127.0.0.1` on any port. One
121
- a first `brass publish` created for you carries only the origin it is
122
- served from, so add `http://localhost:5173/*` on your app's page before
123
- signing in locally. A missing entry answers that the origin is not on
124
- the app's allowlist.
120
+ 3. **Allow your development origin if needed.** When you create an app
121
+ interactively via the CLI or dashboard, it allows sign-in from
122
+ `localhost` and `127.0.0.1` on any port by default. If the app was
123
+ created non-interactively using an automated service token (such as
124
+ in CI), it only permits its deployed origin. In that case, add your local
125
+ dev URL (such as `http://localhost:5173/*`) to the app's allowed origins
126
+ in the dashboard before signing in locally.
125
127
 
126
128
  **CSP.** If (and only if) your app sends a `Content-Security-Policy`,
127
129
  extend it: `script-src` needs `'wasm-unsafe-eval'` for the SDK's
@@ -140,12 +142,12 @@ asset of about 3 MB, an order of magnitude larger than your JavaScript.
140
142
  Serve it as `application/wasm`: browsers compile it while it streams,
141
143
  and fall back to a slower path when the content type is something else.
142
144
 
143
- Boot also requests `/.well-known/brass-runtime.json`. Brass serves that
144
- file from its own hosting to tell a bundle which app and environment it
145
- is being served as. Your own origin has no reason to, so it answers
146
- either a 404 (which the console logs in red) or, if it falls back to
147
- `index.html` the way a dev server does, that page. Both are expected,
148
- and the SDK carries on with the app id and origins you gave it.
145
+ Off Brass hosting (on your own origin or a dev server),
146
+ `Brass.init('brass_app_...')` also requests
147
+ `/.well-known/brass-runtime.json`. Do not serve that file. Your server
148
+ answers with a 404, which the console logs in red, or a dev server answers
149
+ with its `index.html`. Either answer is fine, and the SDK carries on with
150
+ the app id you passed.
149
151
 
150
152
  ### Vite dev server
151
153
 
@@ -153,7 +155,7 @@ Vite pre-bundles your dependencies for its dev server, which copies this
153
155
  package into `node_modules/.vite/deps/` without moving the engine's binary
154
156
  alongside it. The request for the binary then resolves inside that
155
157
  directory, finds nothing, and the dev server answers with `index.html`, so
156
- the engine is handed a page to compile and the app fails when it first
158
+ the engine receives a page to compile and the app fails when it first
157
159
  opens a document. Keep this package out of that step:
158
160
 
159
161
  ```js
@@ -179,9 +181,9 @@ covers when you need one). The second is a typed options object,
179
181
  | `docUrlParam` | `'doc'` (the query param for the active doc id) |
180
182
  | `branchUrlParam` | `'branch'` (the query param for a non-default branch) |
181
183
  | `syncDocToTitle` | `true` (set the page title to `Doc name · App name` while a document is open) |
182
- | `openIncompatibleDocuments` | `false` (Brass refuses to open a document your schema cannot read, and asks the person whether to go ahead) |
184
+ | `open` | `{ readOnly: false }` (set `{ readOnly: true }` to open documents in read-only mode by default) |
183
185
 
184
- `Brass.init` resolves once it knows who the reader is, which takes one
186
+ `Brass.init` resolves once it knows who the user is, which takes one
185
187
  auth round-trip for someone who has signed in to your app before and no
186
188
  request at all for a first-time visitor. Register `onError` and
187
189
  `onUserChange` BEFORE calling `init`, so that validation failures fire
@@ -197,16 +199,18 @@ you. Run it with the `brass` CLI:
197
199
 
198
200
  ```sh
199
201
  npm run build
200
- npx @brass-build/cli publish ./dist
202
+ npx @brass-build/cli publish ./dist --name "My app"
201
203
  ```
202
204
 
203
- The first publish creates the app, enables hosting, uploads the build,
205
+ The first publish creates the app named by `--name`, enables hosting, uploads the build,
204
206
  and writes the app id to `.brass/project.json`. Commit that file so
205
207
  later publishes and your teammates target the same app.
206
208
 
207
- Run `brass login` once first to authenticate (it opens your browser),
208
- or set `BRASS_SERVICE_TOKEN` in a pipeline. The CLI also pulls a
209
- document's schema into your `brass-app.json`. See
209
+ Run `brass login` once first to authenticate. It opens your browser,
210
+ and on a machine without one it prints a URL and a short code you
211
+ approve from any device. In a pipeline, set `BRASS_SERVICE_TOKEN`
212
+ instead. The CLI also pulls a document's schema into your
213
+ `brass-app.json`. See
210
214
  [`@brass-build/cli`](https://www.npmjs.com/package/@brass-build/cli).
211
215
 
212
216
  ## Rendering auth state
@@ -220,16 +224,15 @@ flashes the sign-in screen at signed-in users on every reload. Render
220
224
  the CTA only on `'signed-out'`, and render your working surface for
221
225
  **both** `'signed-in'` and `'guest'`.
222
226
 
223
- `'guest'` is the "anyone with the link" case: someone opened a public
224
- view link, so there is no signed-in user but `Brass.session` still
225
- opens the shared document **read-only** for them (`Brass.isGuest` is
226
- `true`). When the page URL carries the link token, the session boots
227
- that read-only document on its own.
227
+ `'guest'` means the page has a document open without a signed-in user:
228
+ someone opened an "anyone with the link" view link, which the session
229
+ opens **read-only**, or a visitor made a document without an account
230
+ (below), which is editable. `Brass.isGuest` is `true` in both cases.
228
231
 
229
- `Brass.onUserChange` is the single source for reacting to auth
230
- changes. It fires once when `init` resolves (with the real user or
231
- `null`), then on every sign-in and sign-out. A listener registered
232
- before `init` does not fire on registration. One registered after does.
232
+ `Brass.onUserChange(cb)` is the single source for reacting to auth
233
+ changes. It calls `cb` once when `init` resolves, and again whenever
234
+ `Brass.user` or `Brass.authStatus` changes. A listener registered before
235
+ `init` does not fire on registration. One registered after does.
233
236
 
234
237
  The [quickstart](#quickstart) sample gates on `'signed-out'` for exactly
235
238
  this reason. A framework app (React, Vue, Svelte) paints its initial
@@ -245,9 +248,95 @@ Brass.onUserChange(() => { authStatus.value = Brass.authStatus; });
245
248
  ```html
246
249
  <SplashOrNothing v-if="authStatus === 'unknown'" />
247
250
  <SignInCard v-else-if="authStatus === 'signed-out'" />
248
- <AppShell v-else /> <!-- signed-in or guest (read-only) -->
251
+ <AppShell v-else /> <!-- signed-in or guest -->
249
252
  ```
250
253
 
254
+ ### Try before signing in
255
+
256
+ A visitor who can try your app without an account sees it work on their own
257
+ file before deciding to sign up. What your app can offer
258
+ that visitor depends on what it needs from an account: a visitor belongs to
259
+ no organization, so they cannot pick files for `Brass.chooseInputs`, and your
260
+ app cannot call a service from its manifest on their behalf. The session's
261
+ New and Open commands work without an account, so give your signed-out screen
262
+ buttons that run them:
263
+
264
+ ```ts
265
+ signInButton.onclick = () => Brass.signIn();
266
+ newButton.onclick = () => Brass.session.run('new');
267
+ uploadButton.onclick = () => Brass.session.run('open');
268
+ ```
269
+
270
+ For visitors without an account, the `open` command triggers the browser's
271
+ native file picker to upload a local file directly, and its label in
272
+ `Brass.session.commands` changes from "Open" to "Upload". Render the
273
+ button label from that command in your `Brass.session.subscribe` callback
274
+ rather than hardcoding "Open":
275
+
276
+ ```ts
277
+ Brass.session.subscribe((session) => {
278
+ uploadButton.textContent =
279
+ session.commands.find((c) => c.id === 'open')?.label ?? 'Open';
280
+ });
281
+ ```
282
+
283
+ Always trigger `Brass.session.run('open')` directly from a
284
+ button click handler, because browsers only permit file dialogs to open in
285
+ response to a direct user action. The file dialog filters for the file types
286
+ supported by your app.
287
+
288
+ While the file uploads and imports, `Brass.session.status` reads
289
+ `'loading'` and `Brass.authStatus` still reads `'signed-out'`, so your
290
+ signed-out screen renders the loading state. When Brass refuses the file
291
+ or the document, `Brass.session.error` carries the reason.
292
+
293
+ A document created without an account saves and syncs like any other, and a
294
+ reload or return visit in the same browser reopens it. If you use
295
+ `Brass.mountToolbar()`, the toolbar displays an expiration notice
296
+ automatically. If your app builds custom chrome instead, check
297
+ `doc.expiresAt` (which contains a `Date` for temporary documents, and `null`
298
+ for persistent ones) and display the remaining time to the visitor.
299
+
300
+ Do not offer `Brass.chooseInputs` or `Brass.chooseInput` on a temporary
301
+ document, because passing the chosen references to `doc.dependsOn` throws
302
+ there. Only display
303
+ those controls when `Brass.authStatus === 'signed-in'`.
304
+ `doc.subscribeInputDocuments` works on a temporary document and reports no
305
+ inputs, so subscribe without checking the sign-in state.
306
+
307
+ To save a temporary document permanently to an account, prompt the visitor to
308
+ sign in using `Brass.signIn()` (or the sign-in button in
309
+ `Brass.mountToolbar()`). When a visitor signs in from the active document
310
+ page, Brass automatically transfers the temporary document into their workspace.
311
+
312
+ Because a temporary document cannot reference other files, a visitor who is
313
+ not signed in can do nothing in an app whose content comes from the files
314
+ people pick, or in one where every action calls a service. For such an app,
315
+ declare `"create": "signed-in"` and skip the rest of this section. An app that
316
+ uses a service for one feature can still offer the rest to a visitor.
317
+
318
+ To let visitors without an account use your app, declare it in your
319
+ [`brass-app.json`](./docs/app-manifest.md):
320
+
321
+ ```json
322
+ { "access": { "create": "anyone" } }
323
+ ```
324
+
325
+ and publish your app with `--gate off` and `--visibility public`:
326
+
327
+ ```sh
328
+ npx @brass-build/cli publish ./dist --gate off --visibility public
329
+ ```
330
+
331
+ - `--gate off` allows visitors to load your application bundle before
332
+ signing in (by default, Brass requires sign-in before loading the app).
333
+ - `"create": "anyone"` and `--visibility public` allow visitors without an
334
+ account to create, upload and edit temporary documents.
335
+
336
+ A visitor who is not signed in can also open an organization's public links
337
+ in your app. To ask visitors to sign in before a public link opens, set
338
+ `"open": "signed-in"` under `access`.
339
+
251
340
  ## Default UI
252
341
 
253
342
  `Brass.mountToolbar(container)` fills a container with the full
@@ -274,11 +363,10 @@ filling the rest:
274
363
  </div>
275
364
  ```
276
365
 
277
- **Mount it once when the user signs in, not when a document opens.**
278
- New and Open live in its file menu, so a user who has just signed in
279
- reaches a first document only through a bar that is already there. It
280
- re-renders itself as documents open, switch, and close. It takes
281
- options `{ presence?, account?, drive? }` (all default to `true`), and
366
+ **Mount it once when the user signs in or becomes a guest, not when a
367
+ document opens.** New and Open live in its file menu, so the bar has to
368
+ exist before any document does. It re-renders itself as documents open,
369
+ switch, and close. It takes options `{ presence?, account?, drive? }` (all default to `true`), and
282
370
  the returned handle's `destroy()` removes it.
283
371
 
284
372
  A multi-pane app that builds its own header takes the standard pieces
@@ -320,11 +408,11 @@ through the document's own `subscribe` (below), not here.
320
408
  | `status` | Meaning | Render |
321
409
  | ------------ | ---------------------------------------- | ------------------------------------ |
322
410
  | `unknown` | `init` not settled; auth not yet known. | Nothing, or a splash. |
323
- | `signed-out` | No signed-in user. | The sign-in CTA. |
411
+ | `signed-out` | No signed-in user. | The sign-in CTA (and Try without an account, if your app offers it). |
324
412
  | `empty` | Signed in, no document open. | "No document open. Use New or Open." |
325
413
  | `loading` | An open / create is in flight. | A spinner or progress bar (below). |
326
414
  | `ready` | A document is open (`session.document`). | Your editor. |
327
- | `error` | A document couldn't be opened, or the open one was deleted.| `session.error.message` + your recovery actions (below) |
415
+ | `error` | A document couldn't be opened, the open one was deleted, or `init` was misconfigured (no app ID).| `session.error.message` + your recovery actions (below) |
328
416
 
329
417
  Gate edit affordances on `ready`: a canvas that accepts input in the
330
418
  `empty` state lets the user produce work with nowhere to save it.
@@ -356,12 +444,12 @@ indeterminate). The document binds only after its content downloads,
356
444
  so the first `ready` render already has the real model to read
357
445
  directly.
358
446
 
359
- **Work on an open document.** Brass prepares an imported file, syncs a
447
+ **Work on an open document.** Brass imports a new version of a file, syncs a
360
448
  document to its drive, and does other work while the document is open.
361
449
  `doc.status` carries that work and reads `null` when there is none;
362
450
  `doc.onStatus(cb)` fires on every phase. Render `status.message`
363
451
  verbatim and read `status.pending` for whether the work is still
364
- running. The message is prose written for the reader, so show it rather
452
+ running. The message is prose written for the user, so show it rather
365
453
  than parsing it or inferring a phase from it. Both are readable
366
454
  synchronously, so a fresh bind renders the current state without waiting
367
455
  for a fire.
@@ -386,11 +474,12 @@ document, or a platform-chosen home from the empty state.
386
474
  await Brass.session.create({ name: 'Scheme B' });
387
475
  ```
388
476
 
389
- `session.pick()` is the same call for opening an existing document: it
390
- raises the hosted picker and binds what the reader chooses, resolving
391
- `null` if they cancel. Both back the toolbar's own New and Open, so reach
392
- for them when your app renders those buttons itself (an empty state
393
- offering somewhere to start, a shortcut).
477
+ `session.open()` with no arguments opens the hosted document picker and
478
+ opens the selected document in the session, resolving to `null` if the user
479
+ cancels. Use `session.create()` and `session.open()` when your app provides its
480
+ own buttons to create or open documents (such as empty-state prompts or
481
+ keyboard shortcuts). For signed-out visitors, these commands behave as
482
+ described in [Try before signing in](#try-before-signing-in).
394
483
 
395
484
  **Keyboard shortcuts** route through `session.run`, which drives the
396
485
  same undo / redo commands the toolbar buttons use. `session.commands`
@@ -417,9 +506,103 @@ Code that runs outside the render loop awaits the bound document with
417
506
  as `session.document`, immediately if one is already bound, and rejects
418
507
  if the open fails or the user signs out before one binds.
419
508
 
420
- The session's Open and Share commands invoke the hosted dialogs
421
- (`Brass.selectDocument()`, `Brass.shareDocument()`) for you, so
422
- browsing, picking, and sharing an existing document take no code.
509
+ The session's Open and Share commands show the hosted dialogs for you,
510
+ so browsing, picking, and sharing an existing document take no code.
511
+
512
+ **Opening in read-only mode.** By default, the session opens documents for
513
+ editing. If your app only views or inspects documents, set
514
+ `open: { readOnly: true }` in `Brass.init`:
515
+
516
+ ```ts
517
+ await Brass.init(appId, { open: { readOnly: true } });
518
+ ```
519
+
520
+ With this option, the session opens every document in read-only mode (whether
521
+ opened from the toolbar, a direct link, or a page reload).
522
+
523
+ To choose the mode for one document instead, pass `{ readOnly: true }` or
524
+ `{ readOnly: false }` to `session.open`, which overrides the init option for
525
+ that open.
526
+
527
+ Because `session.document` uses the `Document` type in both modes and
528
+ mutating a read-only document throws an error, check `doc.readOnly` before
529
+ displaying editing tools or enabling write actions.
530
+
531
+ If your app edits certain document families and only views others, initialize
532
+ in read-only mode, inspect the document's family, and upgrade when appropriate:
533
+
534
+ ```ts
535
+ const doc = Brass.session.document;
536
+ if (doc?.readOnly && doc.id) {
537
+ const { family } = await Brass.documents.get(doc.id);
538
+ if (family && canEditFamily(family)) await doc.reopen(); // reopens with write access
539
+ }
540
+ ```
541
+
542
+ `session.open(docId, options)` and `Brass.openDocumentById(docId, options)`
543
+ support these modes:
544
+
545
+ - **Default (editable):** Opens the document with full editing capabilities.
546
+ `doc.readOnly` is `false` unless the user only has view access or the
547
+ document is too large to edit (`doc.atCapacity === true`).
548
+ - **`{ readOnly: true }`:** Opens the document in read-only mode while still
549
+ receiving other users' edits in real time. Returns a `ReadOnlyDocument`,
550
+ whose type exposes no mutation methods.
551
+ - **`{ frozen: true }` (supported on `Brass.openDocumentById`):** Opens a
552
+ static, read-only snapshot that disconnects from real-time sync. Use this for
553
+ batch processing, reference data, or background tasks that only need to read
554
+ data once without keeping an open WebSocket connection. Register
555
+ `doc.onChanged(cb)` to detect when newer changes are available on the
556
+ server, and call `await doc.reload()` to update the snapshot to the latest
557
+ version.
558
+
559
+ **Handling open errors and pending imports.** When opening a document backed by
560
+ an imported file, the open waits for any in-progress conversion to finish. If the
561
+ import fails or the file format cannot be opened, the call rejects with a
562
+ `BrassError`. Display `error.message` in your error UI rather than attempting
563
+ to model the import lifecycle. If `subscribeInputDocuments` is watching the file,
564
+ it will invoke your callback once the file becomes openable.
565
+
566
+ **Switching modes with `reopen()`.** Call `await doc.reopen(options)` to switch
567
+ an open document between editable and read-only modes:
568
+
569
+ ```ts
570
+ // Upgrade a read-only document to editable
571
+ await doc.reopen();
572
+
573
+ // Switch to read-only
574
+ await doc.reopen({ readOnly: true });
575
+ ```
576
+
577
+ Brass flushes any unsaved changes, closes the previous instance, and passes the
578
+ new document instance to `session.subscribe`. Reopening for editing rejects with
579
+ a 403 `BrassError` if the user only has view access.
580
+
581
+ **Closing documents.** Call `doc.close()` when your app is finished with a
582
+ document opened via `Brass.openDocumentById()`:
583
+
584
+ ```ts
585
+ const doc = await Brass.openDocumentById(id, { frozen: true });
586
+ try {
587
+ for (const [key, value] of doc.map('elements').entries()) {
588
+ processElement(key, value);
589
+ }
590
+ } finally {
591
+ doc.close();
592
+ }
593
+ ```
594
+
595
+ ### Large imported files
596
+
597
+ If your application only views or inspects documents, open them in read-only mode
598
+ (`{ readOnly: true }` or `Brass.init(appId, { open: { readOnly: true } })`).
599
+ Read-only and frozen modes load significantly faster and require far less bandwidth
600
+ than editable sessions. Unchanged documents can reopen directly from the local
601
+ cache without downloading again.
602
+
603
+ When `doc.atCapacity` is `true`, the document exceeds the maximum size supported
604
+ for collaborative editing. Disable editing tools and bulk operations, and display
605
+ `doc.status.message` to inform the user.
423
606
 
424
607
  ## Editing documents
425
608
 
@@ -446,9 +629,12 @@ function bindEditor(doc: Document): void {
446
629
  ```
447
630
 
448
631
  Compare the **`Document` instance**, never `document.id`. The session
449
- hands you a fresh instance on every open, and a version preview is a
632
+ provides a fresh instance on every open, and a version preview is a
450
633
  new instance with the same id, so an id comparison would skip the
451
- rebind and strand your editor on the previous state.
634
+ rebind and strand your editor on the previous state. Brass also
635
+ swaps in a fresh copy of the document when an import finishes or a
636
+ just-imported document becomes editable, passing it to `session.subscribe`
637
+ once its content is in place, so keep the current view on screen until then.
452
638
 
453
639
  ### Read and mutate
454
640
 
@@ -460,7 +646,7 @@ touches the slice it cares about instead of walking the whole
460
646
  document.
461
647
 
462
648
  The SDK exports each accessor's return type, for a signature or a field
463
- that has to hold one: `MapHandle<T>`, `OrderedMapHandle<T>`,
649
+ that has to store one: `MapHandle<T>`, `OrderedMapHandle<T>`,
464
650
  `ListHandle<T>` and `TextHandle`.
465
651
 
466
652
  **Make an item the smallest thing two people edit independently.** Two
@@ -482,22 +668,39 @@ walls.mutate((m) => { delete m[id]; }); // remove
482
668
  const one = walls.get(id); // Wall | undefined
483
669
  ```
484
670
 
671
+ Every handle reads the same way, whether the document opened editable or
672
+ read-only: `get`, `has`, `keys()` and `entries()` on a map or ordered map,
673
+ and `length()`, `get(index)` and `values()` on a list. `entries()` and
674
+ `values()` walk the container. Render from `subscribe`: after its initial
675
+ invocation, each subsequent callback delivers only what changed.
676
+
677
+ When your app reads a container once (such as for an export, summary count, or
678
+ comparison), walk it with `entries()` or `values()` instead of creating a subscription.
679
+ On large documents, creating a subscription incurs higher overhead because it constructs
680
+ change entries for every existing record upon initial delivery.
681
+
682
+ ```ts
683
+ let total = 0;
684
+ for (const [, wall] of walls.entries()) total += wall.x;
685
+ ```
686
+
485
687
  The draft indexes as `Record<string, T>`, so a read is `T | undefined`
486
688
  and the field edit checks it: under `noUncheckedIndexedAccess` that is
487
689
  the only form that compiles, and either way a peer may have deleted the
488
- record between your render and your click. A record you hold that way is
690
+ record between your render and your click. A record you keep that way is
489
691
  live, so writing through it writes to the document.
490
692
 
491
693
  Handles are cached per key (`doc.map('walls') === doc.map('walls')`),
492
694
  so they compare stable across renders. `doc.list<T>(key)` is the same
493
- surface for a root list; its mutate draft is a plain array proxy, and
494
- indexed access returns a live element, so find an index then edit it in
695
+ surface for a root list; its mutate draft is an array (with a `move`
696
+ method, below), and indexed access returns a live element, so find an index then edit it in
495
697
  place:
496
698
 
497
699
  ```ts
498
700
  doc.list<Col>('cols').mutate((l) => {
499
701
  const i = l.findIndex((c) => c.id === colId);
500
- l[i].name = name; // or l[i] = { ...l[i], name }, or l.splice(i, 1)
702
+ const col = l[i];
703
+ if (col) col.name = newName; // or, when i >= 0, l.splice(i, 1) to remove it
501
704
  });
502
705
  ```
503
706
 
@@ -513,17 +716,17 @@ insertion order and a delete prunes from the order, both in the same
513
716
  commit as the map edit. A hand-rolled key goes wrong here because
514
717
  nothing gives you a stable number to build one from: a count shifts as
515
718
  entries are deleted, so a later entry can sort before an earlier one.
516
- Read the collection in order with `ordered()` or `keys()`.
719
+ Read the collection in order with `entries()` or `keys()`.
517
720
 
518
721
  ```ts
519
722
  const notes = doc.orderedMap<Note>('notes', 'note_order');
520
723
  notes.mutate((m) => { m[id] = { text: '' }; }); // appends to the order
521
724
  notes.mutate((m) => { const n = m[id]; if (n) n.text = 'hi'; }); // order untouched
522
- for (const { key, value } of notes.ordered()) renderRow(key, value);
725
+ for (const [key, value] of notes.entries()) renderRow(key, value);
523
726
  ```
524
727
 
525
728
  `insert(key, value, at)` puts a new key somewhere other than the end,
526
- and `move(key, to)` repositions one the collection already holds. Both
729
+ and `move(key, to)` repositions one the collection already contains. Both
527
730
  write the record and its place in the order in one commit, the way a
528
731
  `mutate` add does.
529
732
 
@@ -534,11 +737,11 @@ notes.move(dragged, { index: 0 }); // send to the top
534
737
  ```
535
738
 
536
739
  Name a neighbouring key where you have one. A key still anchors where
537
- the reader meant after a peer adds or removes a row between your render
740
+ the user meant after a peer adds or removes a row between your render
538
741
  and your write, where an index by then counts different rows.
539
742
  `{ index: n }` counts the positions `keys()` reports and leaves the key
540
- at `n`, or last when `n` is past the end. Two readers inserting at one
541
- spot both keep their record, and a reader who repositions a record
743
+ at `n`, or last when `n` is past the end. Two users inserting at one
744
+ spot both keep their record, and a user who repositions a record
542
745
  another deletes leaves the delete standing.
543
746
 
544
747
  A field edit leaves the order alone, so its `subscribe` fire carries
@@ -554,16 +757,15 @@ operates on the same state as the handles, so an app can mix the two.
554
757
  Three mutation rules:
555
758
 
556
759
  - **Edit a nested list by assigning a new array, or through its own
557
- handle.** `m[id].tags = [...m[id].tags, x]` persists wherever the
558
- record is stored, and `parent.list(key)` gives a list you edit often a
559
- handle of its own. Nested **maps** walked through the proxy are live.
760
+ handle.** `m[id].tags = [...m[id].tags, x]` persists, and you can give
761
+ a list you edit often its own handle with `parent.list(key)`. Nested **maps** walked through the proxy are live.
560
762
  - **Don't write `undefined`.** Mutations JSON-serialise, so
561
763
  `undefined` inside an array becomes `null` and `undefined` property
562
764
  values are dropped. Use `null`, length checks, or `'key' in obj`
563
765
  instead.
564
- - **Keys are yours except for the `brass:` prefix.** A map keeps its own
565
- bookkeeping beside your records, under keys beginning with `brass:`,
566
- so a write under one is refused. Any other key is free.
766
+ - **Keys are yours except for the `brass:` prefix.** Writing under a key
767
+ that begins with `brass:` throws. Keys under `brass:` never appear in
768
+ `keys()`, `entries()` or `get`, so iterate without filtering.
567
769
 
568
770
  A document starts **empty**, and the user's first action is their
569
771
  first undoable edit. Don't seed a baseline from the client: a faked
@@ -603,16 +805,16 @@ report.subscribe((value, origin) => {
603
805
 
604
806
  `origin` says who wrote the change, the way a map subscriber's does. The
605
807
  app's own write comes back through the subscriber too, and it is the one
606
- fire that must not touch the field: the reader is already looking at what
808
+ fire that must not touch the field: the user is already looking at what
607
809
  they typed. Every other fire has to paint without throwing away the caret
608
810
  of whoever is typing, which is what `paintPeerEdit` above stands for.
609
811
 
610
812
  **A text container is a top-level root of the document**, so take one per
611
813
  document-shaped thing: a body, a section, a summary. Prose that belongs to
612
814
  each row of a repeated collection stays in the row's record, where the row
613
- is the merge unit and the record store's paging holds the cost flat as the
614
- collection grows. A root per row opts out of that paging, and loro prices a
615
- container against how many the document holds, so the cost climbs steeply
815
+ is the merge unit and the record store's paging keeps the cost flat as the
816
+ collection grows. A root per row opts out of that paging, and each extra root
817
+ makes every container in the document cost more, so the cost climbs steeply
616
818
  with the collection.
617
819
 
618
820
  [`docs/text-fields.md`](./docs/text-fields.md) carries the caret-preserving
@@ -623,12 +825,16 @@ and the measurements behind the root-per-row rule.
623
825
 
624
826
  `handle.subscribe(cb)` is one channel for every change, whatever
625
827
  produced it: your own edit, a remote peer's, an undo, a move through
626
- version history. It fires once on registration with the whole container as
627
- `change.set`, then on every change, and each fire carries what that
628
- change wrote, so editing one record costs one entry whatever the
629
- document holds. Your own edits arrive here too, so render
630
- from this one place: an app that also paints at the edit site updates
631
- the field it touched and leaves a header showing the same value stale.
828
+ version history. It fires once with the whole container as `change.set`
829
+ before `subscribe` returns, then on every change, and each fire carries
830
+ what that change wrote, so editing one record costs one entry whatever
831
+ the document contains. Subscribe to render and to follow changes. To read a
832
+ container once, walk it instead (see [Read and mutate](#read-and-mutate)).
833
+ Because the callback executes synchronously during `subscribe` before the method
834
+ returns, declare and initialize all variables referenced by the callback before
835
+ subscribing to avoid runtime `ReferenceError`s. Your own edits arrive here too,
836
+ so render from this single place: updating UI directly at the edit site often
837
+ leaves other views showing stale data.
632
838
 
633
839
  ```ts
634
840
  const stop = walls.subscribe((change) => {
@@ -665,10 +871,14 @@ In a declarative framework, bind the input to a per-field ref (`v-model`,
665
871
  `useState`) rather than to the document, and have your subscriber assign
666
872
  that ref for every field except the focused one.
667
873
 
668
- **A subscriber runs inside the `mutate` that caused it**, so what it
669
- gave you is current the moment `mutate` returns. Answer a read straight
670
- after a write (allocating the next id, checking a new name against the
671
- ones in use) from what you already hold.
874
+ **Subscribers execute synchronously inside `mutate()`.** The updated document
875
+ state is guaranteed to be in place the moment `mutate()` returns, so subsequent reads
876
+ (such as allocating the next ID or validating against existing names) can use current
877
+ values immediately.
878
+
879
+ Similarly, any DOM updates rendered by your subscriber are applied before `mutate()`
880
+ returns. You can immediately focus, scroll to, or measure newly created elements
881
+ right after calling `mutate()`.
672
882
 
673
883
  A `list` handle's subscriber receives splices, each carrying the
674
884
  position an edit lands at, applied in the order they arrive:
@@ -682,8 +892,8 @@ const stop = steps.subscribe((change) => {
682
892
  ```
683
893
 
684
894
  **A reorder arrives as a removal and an insertion, so pair them if the
685
- item owns anything.** Rebuilding is correct and costs whatever the item
686
- holds: a DOM node loses its focus, its selection and its scroll
895
+ item owns anything.** Rebuilding is correct and costs whatever state the
896
+ item carries: a DOM node loses its focus, its selection and its scroll
687
897
  position, and a view built on it starts over. As you apply a splice you
688
898
  know which of your own items it removed, so match them against what the
689
899
  same change inserts and move the existing item rather than rebuilding it.
@@ -701,7 +911,9 @@ const stop = steps.subscribe((change) => {
701
911
  An `orderedMap` fire also carries `change.order` (the live ids in
702
912
  order) when the order moves. The optional second callback argument
703
913
  `origin` (`{ source: 'local' | 'remote' | 'undo' | 'redo' }`) carries
704
- attribution, for a render that highlights a peer's edit. A move through
914
+ attribution, for a render that highlights a peer's edit. An Undo or Redo
915
+ can fire your subscriber several times, once per commit the step took. A
916
+ move through
705
917
  version history swaps the whole container rather than writing into it, so
706
918
  it arrives with no `origin`; `doc.subscribe` reports that move, with the
707
919
  version it landed on.
@@ -718,13 +930,15 @@ walls.onSettled(() => fitViewTo(walls));
718
930
  ```
719
931
 
720
932
  It fires on registration, and again each time a version checkout, a
721
- return to head or a hydrate replaces the container, so a reader who
933
+ return to head or a hydrate replaces the container, so a user who
722
934
  moves through history gets the camera framed on the version they landed
723
935
  on. Set a flag of your own on the first fire for work that should run
724
936
  once.
725
937
 
726
938
  It reports what this peer had converged to when the delivery began.
727
- Later changes arrive through `subscribe`.
939
+ Later changes arrive through `subscribe`. A view built from several
940
+ streams is whole once the handle of every stream it reads has settled
941
+ (see [Knowing when every stream has arrived](./docs/streams.md#knowing-when-every-stream-has-arrived)).
728
942
 
729
943
  `doc.subscribe(cb)` is the catch-all for events not tied to one
730
944
  container (saving-count ticks, branch-list changes). Use it for status
@@ -735,9 +949,7 @@ surfaces, and render the editor from handles rather than from a
735
949
 
736
950
  The `undo` and `redo` commands in the session's command list are fully
737
951
  wired: the toolbar renders them, and keyboard shortcuts reach them
738
- through `session.run`. The undo stack is local-peer-scoped, so
739
- `undo()` only reverts commits this client made and a collaborator's
740
- edits stay untouched. Outside the session, call `doc.undo()` /
952
+ through `session.run`. Outside the session, call `doc.undo()` /
741
953
  `doc.redo()` directly and read the enabled state via
742
954
  `doc.subscribeUndoState(cb)`.
743
955
 
@@ -746,30 +958,45 @@ boundary and a marker on the document's version-history timeline, so
746
958
  checkpoint timing sets both granularities. Automatic checkpoints land 60
747
959
  seconds after each burst of local edits settles. Tune that to your app's shape:
748
960
 
749
- | Your app shape | What to do | Result |
750
- | --------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
751
- | Default / no opinion | Nothing. | A checkpoint every 60s of idle. |
752
- | Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`. | One drag = one Ctrl-Z. |
753
- | Text editor / fast bursts | `doc.setAutoCheckpointIntervalMs(2000)` at boot. | Each typing pause becomes a boundary. |
754
- | Explicit-only | `doc.setAutoCheckpointIntervalMs(0)` | Only deliberate `createCheckpoint` calls. |
755
-
756
- `createCheckpoint()` waits for the edits your app has just made to save
757
- before it marks the point, so the marker describes the state you asked to
758
- save rather than one those edits are missing from. It rejects on a rate
759
- limit, and when those edits do not save. A missed checkpoint is harmless,
760
- so call it fire-and-forget.
961
+ | Your app shape | What to do | Result |
962
+ | --------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------- |
963
+ | Default / no opinion | Only the end-of-action checkpoint below. | Plus one 60s after edits settle. |
964
+ | Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`. | One drag = one Ctrl-Z. |
965
+ | Text editor | `void doc.createCheckpoint()` in `beforeinput` (below). | One typing run or paste = one Ctrl-Z. |
966
+ | Explicit-only | `doc.setAutoCheckpointIntervalMs(0)` | Only deliberate `createCheckpoint` calls. |
967
+
968
+ Call `void doc.createCheckpoint()` at the end of every user action. It
969
+ ends the Ctrl-Z step at the call, so do not await it in the handler:
970
+ the next edit starts a new step either way. Its Promise resolves once
971
+ Brass has saved your changes, including edits the user made after the
972
+ call, and written the version-history marker. If you await it, catch the rejection it raises when Brass cannot write the
973
+ marker, for example because Brass could not save your changes.
974
+
975
+ In a text editor, typing has no end-of-action event, so decide in
976
+ `beforeinput` whether the coming edit joins the open step, and call
977
+ `void doc.createCheckpoint()` there when it does not. Make a paste, cut,
978
+ drop or line break a step of its own (`inputType` `insertFromPaste`,
979
+ `deleteByCut`, `insertFromDrop`, `insertLineBreak` / `insertParagraph`)
980
+ by starting a new step before it and before the edit after it. Also
981
+ start a new step when the user switches between typing and deleting
982
+ (`insert*` and `delete*`), when the edit replaces a selection, and when
983
+ it lands away from where the last edit left the caret. Do not shorten
984
+ the automatic interval to get this. A shorter interval still ends a step
985
+ only at a pause, so one Ctrl-Z undoes everything the user typed since the
986
+ last pause, a paste included.
761
987
 
762
988
  The SDK renders version preview itself: while an earlier version is
763
989
  showing, `mountToolbar` / `mountHeader` show the "viewing an earlier
764
990
  version" banner and return-to-live control (mount `mountVersionPreview`
765
991
  for a fully custom shell). You handle your own edit controls:
766
- `doc.readOnly` flips `true` during a preview, for a reader whose access
767
- to the document is view-only, for a view-only share link, and while a
768
- document is loading or could not be loaded. Gate your custom tools on
769
- it, and `doc.mutate` throws while read-only.
992
+ `doc.readOnly` flips `true` during a preview, for a user whose access
993
+ to the document is view-only, for a view-only share link, while a
994
+ document is loading, is being imported, or could not be loaded, and for a
995
+ file too large to edit (see [Large imported files](#large-imported-files)).
996
+ Gate your custom tools on it, and `doc.mutate` throws while read-only.
770
997
 
771
998
  Read it inside your `doc.subscribe` handler rather than once when you
772
- bind the editor. It also flips when someone withdraws the reader's
999
+ bind the editor. It also flips when someone withdraws the user's
773
1000
  access while they have the document open, so re-reading is what
774
1001
  disables your tools at the moment writing stops working. `Brass.onError`
775
1002
  carries the message explaining what happened.
@@ -810,9 +1037,9 @@ flag.
810
1037
 
811
1038
  `doc.presence` shows who else is in the document and what they are
812
1039
  doing: a roster of the other live participants plus a small per-user
813
- `state` (cursor, camera, selection). It is non-null on any open
814
- writable document, and it is fully ephemeral: nothing survives a
815
- disconnect. `Brass.session.presence` is the same client for the
1040
+ `state` (cursor, camera, selection). It is non-null on any live open,
1041
+ editable or `{ readOnly: true }`, `null` on a `{ frozen: true }` copy, and it is fully ephemeral: nothing
1042
+ survives a disconnect. `Brass.session.presence` is the same client for the
816
1043
  session's open document, so use whichever handle you have.
817
1044
 
818
1045
  Presence requires publishing your own state and rendering everyone
@@ -834,24 +1061,26 @@ Z-up; a 2D app passes `z: 0`), and an entity or text editor publishes
834
1061
  publish a bounded summary (the first few ids plus a count, or the
835
1062
  selection's bounding box) once a selection can get large, and subscribe
836
1063
  with `presence.onStatus` to catch a layer Brass withholds as oversized.
837
- `Brass.mountToolbar` and `Brass.mountPresenceRoster` render the header
1064
+ Set a key to `undefined` to remove it for every peer
1065
+ (`set({ state: { selection: undefined } })` when the user clears a
1066
+ selection). `Brass.mountToolbar` and `Brass.mountPresenceRoster` render the header
838
1067
  roster for you. To render remote cursors, diff the roster by
839
1068
  `connectionId` on the `change` event. The full recipe, coordinate
840
1069
  conventions, and types are in [`docs/presence.md`](./docs/presence.md).
841
1070
 
842
- ## Streams (partitioning large documents)
1071
+ ## Streams (loading a document in parts)
843
1072
 
844
- You can split a document into named streams, each an independently
845
- loaded unit of its state. Opening a document costs only the
846
- streams your app touches, so a large design partitioned by discipline
847
- opens at the size of one discipline, not the whole building:
1073
+ A document is made of named streams, and each stream loads
1074
+ independently. When your app opens a document, it downloads only the
1075
+ streams it reads. Put a heavy part of a document (a terrain mesh, a
1076
+ point-cloud index) in its own stream, and only the opens that draw it
1077
+ download it. An importer can split a document the same way, so a
1078
+ viewer that draws a model opens only its geometry stream:
848
1079
 
849
1080
  ```ts
850
- const hvac = await doc.stream('hvac'); // downloads + subscribes hvac only
851
- const records = hvac.orderedMap('instances', 'instance_order');
852
- records.mutate((m) => {
853
- m[id] = { type, params };
854
- });
1081
+ const geometry = await doc.stream('geometry'); // downloads this stream only
1082
+ const records = geometry.orderedMap('instances', 'instance_order');
1083
+ records.subscribe((change) => draw(change.set));
855
1084
  ```
856
1085
 
857
1086
  Which call you reach for depends on what you already know about the
@@ -862,26 +1091,27 @@ document:
862
1091
  large document downloads only that part. It defaults to `'default'`,
863
1092
  the stream every document has.
864
1093
  - `doc.getOrCreateStream(name)` opens the document's stream of that name
865
- and makes it when the document does not hold it yet. It is idempotent,
866
- and concurrent callers converge on one stream, so your app reaches for
867
- the stream it expects on every open.
868
- - `doc.stream(name)` opens one the document already holds and rejects
869
- any other name. Ask it for the stream you opened the document at and
870
- you get the document back, so one loop over `doc.listStreams()` reads
871
- a partitioned and an unpartitioned document alike.
872
-
873
- A stream costs one extra round trip, so add one when it carries real
874
- content: partition by load unit (a discipline, a heavy layer), not by
875
- concept.
876
-
877
- A schema names the streams it expects under `streams`. If your app
878
- follows references between records, read every stream the document holds
879
- (`doc.listStreams()`). A document can hold streams your schema does not
880
- name, and your app leaves those alone. Naming a stream is how records
881
- you work with (clash findings, review notes, a cache) ride in the
882
- document: they sync and version with it, and an exporter composes only
883
- the streams it declares, so records in a stream it does not name stay
884
- out of the exported file. See [`docs/streams.md`](./docs/streams.md) and
1094
+ and creates it if the document does not have it yet. It is idempotent,
1095
+ and concurrent callers get the same stream, so your app can call it on
1096
+ every open.
1097
+ - `doc.stream(name)` opens one the document already has and rejects
1098
+ any other name. Calling `doc.stream(name)` for the stream the document was
1099
+ opened with returns the document itself, allowing a single loop over
1100
+ `doc.listStreams()` to handle single-stream and multi-stream documents alike.
1101
+
1102
+ Each stream costs one extra round trip to open, so split a document by
1103
+ what each open loads, not by concept.
1104
+
1105
+ A schema names the streams it expects under `streams`. Read only the
1106
+ streams that contain the records your app needs, because Brass downloads
1107
+ and decodes each stream you read in full. If your app follows references
1108
+ into streams it does not otherwise read, read every stream the document
1109
+ has (`doc.listStreams()`). A document can have streams your schema does
1110
+ not name, and your app leaves those alone. Naming a stream allows auxiliary
1111
+ data (such as clash findings, review notes, or cache records) to travel
1112
+ with the document: they sync and version alongside it, while exporters
1113
+ compose only the streams they declare, leaving undeclared streams out of
1114
+ the exported file. See [`docs/streams.md`](./docs/streams.md) and
885
1115
  [`docs/schemas.md`](./docs/schemas.md).
886
1116
 
887
1117
  ## Imported files
@@ -895,7 +1125,7 @@ What you build depends on whether an importer for the format already
895
1125
  exists. Open a document of that type in the dashboard to see which case
896
1126
  you are in:
897
1127
 
898
- - **It holds records under a schema.** An importer for the format
1128
+ - **It has records and a schema.** An importer for the format
899
1129
  already exists (an installed app provides it). Adopt the document's
900
1130
  schema (below) and read and edit the records through the ordinary
901
1131
  handle surface
@@ -904,28 +1134,33 @@ you are in:
904
1134
  serialises the format. A large
905
1135
  document may spread its records across several streams; if your app
906
1136
  follows references between records, read every stream the document
907
- holds, see
1137
+ has, see
908
1138
  [streams](./docs/streams.md#reading-a-document-spread-across-streams).
909
- - **It holds the stored file under no schema.** Nothing imports the
910
- format yet, so your app is its first importer, and your importer is
911
- what makes files of that format openable. Register both halves at
912
- boot: the importer parses the stored file into records, the serializer
913
- reconstitutes the file from them.
1139
+ - **It has only the stored file, with no existing schema.** Nothing imports the
1140
+ format yet, so your app is its first importer, and your importer makes
1141
+ files of that format openable. Register both halves during application
1142
+ startup (unconditionally, not after user sign-in or after a document opens).
1143
+ Brass can load your application in a background headless frame specifically
1144
+ to run its importer, where no user signs in and no document is displayed.
914
1145
 
915
1146
  ```ts
916
- Brass.registerImporter(async (src, doc) => {
917
- const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
918
- const records = doc.orderedMap('instances', 'instance_order');
919
- records.mutate((m) => {
920
- for (const r of parseMyFormat(bytes)) m[r.id] = { type: r.type, params: r.body };
921
- });
1147
+ Brass.registerImporter(async (file) => {
1148
+ const parsed = parseMyFormat(await file.bytes());
1149
+ const instances: Record<string, unknown> = {};
1150
+ for (const r of parsed) instances[r.id] = { type: r.type, params: r.body };
1151
+ return {
1152
+ streams: {
1153
+ default: { instances, instance_order: parsed.map((r) => r.id) },
1154
+ },
1155
+ };
922
1156
  });
923
1157
  Brass.registerSerializer((doc) => serializeMyFormat(doc));
924
1158
  ```
925
1159
 
926
- Brass runs the importer when the file needs importing, and the
927
- records reach every open client through its normal subscription.
928
- Declare the `schema` of the records your importer writes in
1160
+ The importer takes the file and returns the document's content as JSON.
1161
+ Brass runs it when a document needs an import, and every client with
1162
+ the document open then receives the whole result together.
1163
+ Declare the `schema` of the records your importer returns in
929
1164
  `brass-app.json`; importing publishes that schema and stamps it on
930
1165
  the document, so the next app can adopt it like any imported
931
1166
  document's. Declare the file match there too
@@ -939,10 +1174,13 @@ one that another app produced. Copy its schema from the document's page
939
1174
  in the dashboard (**Download brass-app.json**, or
940
1175
  `npx @brass-build/cli schema pull --doc <docId>`), keep it verbatim as
941
1176
  `schema` in your `brass-app.json`, and ship that manifest with your
942
- first deploy. The picker lists documents by matching your declared
943
- schema, and its family folds into your `opens`, so documents of that
944
- shape route to your app and your code stays compatible with the other
945
- apps that edit them.
1177
+ first deploy. The schema describes each record's keys and value shapes.
1178
+ Inspect records before writing code against them:
1179
+ `npx @brass-build/cli contents --doc <docId>` outputs the default stream as
1180
+ JSON (add `--stream <name>` to view another stream). The picker lists
1181
+ documents matching your declared schema, and Brass adds the matching family to
1182
+ your `opens` list so compatible documents route to your app and remain
1183
+ interoperable with other apps editing them.
946
1184
 
947
1185
  Every app ships a capability manifest at `/.well-known/brass-app.json`
948
1186
  in its bundle root, next to `index.html`. It declares the schema your
@@ -956,10 +1194,249 @@ goes, what each key does, and how to confirm your build carries it.
956
1194
  record store an imported file becomes, and
957
1195
  [`docs/schemas.md`](./docs/schemas.md) covers declaring its schema.
958
1196
 
1197
+ ## Referencing other documents
1198
+
1199
+ Your app can reference other documents as inputs. For example, you can calculate the total
1200
+ floor area from multiple CAD models across several projects. Brass automatically
1201
+ tracks referenced documents and notifies your app when any of them change, even
1202
+ if they were edited while your document was closed.
1203
+
1204
+ ### Choosing inputs
1205
+
1206
+ `Brass.chooseInputs(families, options?)` opens the hosted picker where the
1207
+ user selects files, folders, and projects. Pass the families to offer,
1208
+ such as `['ifc']` for IFC models. Each family must be listed in your manifest's
1209
+ `opens` or declared as an `inputs` schema `family`
1210
+ ([`docs/app-manifest.md`](./docs/app-manifest.md)). The picker offers a
1211
+ document only when it satisfies all required fields of a declared schema, so
1212
+ require only the fields your code reads.
1213
+
1214
+ To find the families an organization's files belong to, run:
1215
+
1216
+ ```sh
1217
+ npx @brass-build/cli contents --from organization:<id> --max-documents 0
1218
+ ```
1219
+
1220
+ Files listed with no `family` have no registered importer and are not
1221
+ offered by the picker.
1222
+
1223
+ `Brass.chooseInputs` resolves with:
1224
+ - `InputReference[]`: The selected references. If the user deselects all inputs
1225
+ and confirms, it resolves with an empty array (`[]`).
1226
+ - `null`: The user dismissed or closed the picker without confirming. Keep the
1227
+ references your app already has.
1228
+
1229
+ A reference (`InputReference`) identifies a chosen file, folder, or project,
1230
+ and records the families and filter criteria under which it was selected. Pass
1231
+ current references as `selected` so the picker opens with them pre-selected.
1232
+ The picker expands zip archives into folders, allowing users to select an
1233
+ archive or files inside it without your app declaring a zip importer.
1234
+
1235
+ To pick a single file (such as a symbol library or reference standard), call
1236
+ `Brass.chooseInput(families, options?)`. It opens a single-file selection dialog
1237
+ and resolves with an `InputReference`, or `null` if the user closes the dialog.
1238
+ On `null`, keep your existing reference. Pass the current reference as
1239
+ `selected` on the next call to highlight that file. To let the user remove the
1240
+ reference, provide a clear button in your app UI and clear the reference yourself.
1241
+
1242
+ Both picker methods support these options:
1243
+ - `selected`: Current `InputReference[]` (or `InputReference` for `chooseInput`) to mark as selected.
1244
+ - `accept`: Narrow offered files by name or extension (`{ names?: string[], extensions?: string[] }`).
1245
+ - `prompt`: A custom instruction string displayed in the picker header.
1246
+
1247
+ Record chosen references in the document with `doc.dependsOn(key, refs)`,
1248
+ and clear a key with `doc.dependsOn(key, null)`. Assign a distinct key for each
1249
+ set of inputs (such as `'areas@v1'` or `'rates@v1'`), so independent parts of
1250
+ your app manage their references separately without conflicting. When your calculation logic
1251
+ or schema changes, switch to a new key (such as `'areas@v2'`), which reprocesses
1252
+ every input from scratch, and clear the deprecated key. A document cannot depend
1253
+ on itself.
1254
+
1255
+ Because `doc.dependsOn()` mutates the document, Undo reverts dependency changes
1256
+ alongside edits made in the same action. It throws an error on a read-only document,
1257
+ so verify `!doc.readOnly` before displaying picker buttons, or upgrade with
1258
+ `await doc.reopen()`.
1259
+
1260
+ To record references on a specific [stream](#streams-loading-a-document-in-parts),
1261
+ call `stream.dependsOn(key, refs)` and follow them with
1262
+ `stream.subscribeInputDocuments(key, handlers)`.
1263
+
1264
+ ### Reacting to changes
1265
+
1266
+ A key's inputs are the documents its references include: each directly referenced
1267
+ document, and every document in a referenced folder or project that matches the
1268
+ families and `accept` criteria under which the reference was chosen. A reference
1269
+ to a project or folder stops at an archive inside it; to read an archive's contents,
1270
+ reference the archive itself.
1271
+
1272
+ `doc.subscribeInputDocuments(key, handlers, options)` calls your handlers with
1273
+ the key's inputs and returns an unsubscribe function. Pass the handlers that
1274
+ match where your app stores what it reads:
1275
+
1276
+ - `loaded(input)` runs for every input each time you subscribe, including after
1277
+ a page reload, and again for each new version. It runs for every user, including
1278
+ view-only users. Use it for state your app keeps in memory, such as a viewer
1279
+ that draws every model. To list or table the inputs, render `status.inputs` (below)
1280
+ instead, which opens none of them.
1281
+ - `changed(input)` runs for each input added or edited since your document last
1282
+ read it, including edits made while nobody had your document open. Newly picked
1283
+ inputs count as added and pass to `changed`. When the handler resolves, Brass
1284
+ records that your document has read the input at that version, so subsequent
1285
+ sessions and collaborators do not re-read it. Use it for results your app stores
1286
+ in your document. It runs only for users with edit access.
1287
+ - `removed(docId)` runs once for each input that is no longer among the key's
1288
+ inputs (deleted or moved out of every referenced folder and project). Every user
1289
+ runs it for inputs passed to `loaded`, and an editor also runs it for inputs your
1290
+ document read in an earlier session. Clean up stored or cached results here.
1291
+
1292
+ Pass both when your app does both, such as a viewer that draws each model from
1293
+ `loaded` and stores each model's clash count from `changed`. Brass never runs two
1294
+ handlers for one input at the same time.
1295
+
1296
+ When several subscriptions to one key on a document are live, each input's call
1297
+ runs the handlers of every subscription side by side, and Brass records a
1298
+ `changed` input as read only after every subscription's `changed` has resolved.
1299
+ If one rejects, Brass retries that subscription alone. In a framework component,
1300
+ return the unsubscribe function from your effect's cleanup. Otherwise the
1301
+ subscription outlives its component, keeps writing results, and delays Brass
1302
+ recording inputs as read.
1303
+
1304
+ `input` carries `doc_id`, `name`, `family` (absent on a document with none),
1305
+ `updated_at`, and `from` (the references that include it, nearest first). Call
1306
+ `input.open(options)` to read its content. It accepts the same options as
1307
+ `Brass.openDocumentById`:
1308
+ - From `changed`, open with `{ frozen: true }`, so the handler reads a snapshot
1309
+ without opening a real-time connection.
1310
+ - From `loaded`, open with `{ readOnly: true }`, so a viewer receives live updates
1311
+ without write overhead.
1312
+
1313
+ Calling `input.open` again in the same handler with matching options returns the
1314
+ same document instance; calling it with different options closes the previous
1315
+ copy and opens a new one.
1316
+
1317
+ Brass automatically closes the document returned by `input.open` when your handler
1318
+ resolves, and logs a warning if your app uses it after that. To keep it open
1319
+ between calls (such as in a viewer that keeps every model loaded and subscribed to),
1320
+ pass `{ keepDocumentsOpen: true }`. Subsequent `input.open` calls for that input,
1321
+ from `loaded` or `changed`, then return the same document, reloaded at the new
1322
+ version if frozen. Brass closes it when the input is removed or you unsubscribe.
1323
+ Call `input.close()` to close it earlier.
1324
+
1325
+ **Writing results with `{ undoable: false }`.** Write each input's result in
1326
+ `changed` using `mutate(draft, { undoable: false })`, so user Undo actions leave
1327
+ derived results in place, and delete it in `removed`. Compute each result from that
1328
+ input alone, and write deterministic results for the same input wherever it runs.
1329
+
1330
+ **Concurrency and retries.** Brass calls handlers for multiple inputs concurrently
1331
+ and automatically paces them. Brass never calls a handler for an input while an
1332
+ earlier call for it is still running. If an input is edited while your handler reads
1333
+ it, Brass calls the handler again with the new version and discards the earlier call.
1334
+ Leave the pace to Brass unless each handler spends something rationed, such as
1335
+ calls to a paid third-party API. To cap how many run at once for such a handler,
1336
+ pass `{ concurrency: n }`. A lower cap makes reading many inputs take longer.
1337
+ A handler that rejects leaves the input unread, and Brass retries with
1338
+ exponential backoff (2 seconds up to 5 minutes), so do not retry it yourself. If `input.open` rejects with `'not_imported'`,
1339
+ show `error.message` for that input and rethrow. Its import runs only when someone
1340
+ opens the file in the app that imports its type, so Brass does not retry it on the
1341
+ backoff and instead re-invokes your handler once that app has imported the file.
1342
+
1343
+ To read an input on demand (such as when its table row scrolls into view), keep its
1344
+ handler pending until then and call `input.open` before the handler resolves. A
1345
+ pending handler does not delay handlers for other inputs, and `status.settled` stays
1346
+ `false` until it resolves.
1347
+
1348
+ `handlers.status(status)` receives the key's status, and again whenever it changes:
1349
+
1350
+ - `status.refs` lists the key's references. Pass them back to `chooseInputs` as
1351
+ `selected`.
1352
+ - `status.inputs` lists the inputs this user can open, with the same fields as
1353
+ `input`. Render lists directly from this array without opening documents. To
1354
+ render the enclosing folders for an input, read `member_of.chain` from
1355
+ `Brass.documents.get(input.doc_id)`: the folders and archives above it, outermost
1356
+ first, ending with the folder or archive `member_of` names.
1357
+ - `status.hidden_count` counts inputs your document read before that this user
1358
+ cannot open. When greater than zero, notify the user that results include inputs
1359
+ they cannot see.
1360
+ - `status.truncated` is `true` when your document's keys together include more
1361
+ inputs than a document tracks. Notify the user that some inputs are omitted.
1362
+ - `status.settled` is `false` while the list of inputs may not be complete (such
1363
+ as right after `dependsOn`), and until every handler you passed has finished for
1364
+ every input and inputs have stopped changing. Display progress indicators while
1365
+ it is `false`. A rejected handler counts as finished, so `settled` can turn `true`
1366
+ with errors outstanding; store each error in your handler and show it in the
1367
+ input's row.
1368
+
1369
+ `handlers.settled(status)` runs each time `status.settled` turns `true`: after the
1370
+ first load, and again after each later batch of additions, edits, or removals. A
1371
+ burst of inputs arriving together (such as a drive's first listing) produces one
1372
+ call. Run work that reads every input's result together here (such as numbering
1373
+ doors across models, or combining results with a second key's inputs, such as a
1374
+ rate table), so a change to either updates every result. Because every user runs it,
1375
+ write to the document only when `!doc.readOnly`.
1376
+
1377
+ ### Example
1378
+
1379
+ The user picks projects, and a report shows the floor area of each model
1380
+ in them. Each model's area appears as soon as its `changed` handler
1381
+ finishes, and the total updates with each one:
1382
+
1383
+ ```ts
1384
+ import type { Document, InputReference } from '@brass-build/client';
1385
+
1386
+ const KEY = 'areas@v1';
1387
+ let report: Document | null = null;
1388
+ let refs: InputReference[] = [];
1389
+ let unbind: (() => void) | null = null;
1390
+
1391
+ // Called from `session.subscribe` whenever `session.document` changes.
1392
+ function bindReport(doc: Document | null) {
1393
+ unbind?.();
1394
+ unbind = null;
1395
+ report = doc;
1396
+ if (!doc) return;
1397
+ const areas = doc.map<number>('areas');
1398
+ const stopInputs = doc.subscribeInputDocuments(KEY, {
1399
+ async changed(input) {
1400
+ const model = await input.open({ frozen: true });
1401
+ const area = sumFloorArea(model);
1402
+ areas.mutate((draft) => { draft[input.doc_id] = area; }, { undoable: false });
1403
+ },
1404
+ removed(docId) {
1405
+ areas.mutate((draft) => { delete draft[docId]; }, { undoable: false });
1406
+ },
1407
+ status(status) {
1408
+ refs = status.refs;
1409
+ showProgress(!status.settled);
1410
+ },
1411
+ });
1412
+ const stopTotal = areas.subscribe(() => renderTotal(areas));
1413
+ unbind = () => { stopInputs(); stopTotal(); };
1414
+ }
1415
+
1416
+ async function choose() {
1417
+ const doc = report;
1418
+ if (!doc || doc.readOnly) return;
1419
+ const picked = await Brass.chooseInputs(['ifc'], { selected: refs });
1420
+ // The session may have bound another document while the picker was open.
1421
+ if (picked !== null && report === doc) doc.dependsOn(KEY, picked);
1422
+ }
1423
+ ```
1424
+
1425
+ Every `doc.reopen()` passes a new document instance to `session.subscribe`, so
1426
+ unsubscribe the old document's inputs before you subscribe the new one's,
1427
+ as `bindReport` does.
1428
+
1429
+ To reference a single library file (such as a symbol catalog), pass the library's
1430
+ family to `Brass.chooseInput` (`npx @brass-build/cli contents --from organization:<id> --max-documents 0`
1431
+ lists each file's `family`), and record the returned reference with
1432
+ `doc.dependsOn(key, [ref])`. When collaborators edit the library, Brass calls
1433
+ `changed` with it: open it with `{ frozen: true }` and update the placements that
1434
+ use it.
1435
+
959
1436
  ## Calling an outside service
960
1437
 
961
1438
  Your app can call a paid or authenticated third-party API without ever
962
- holding its credential. Declare the host in `brass-app.json`, and Brass adds
1439
+ having its credential. Declare the host in `brass-app.json`, and Brass adds
963
1440
  the key to each request:
964
1441
 
965
1442
  ```json
@@ -1043,12 +1520,13 @@ above show them in use.
1043
1520
  | `Brass.session` | `DocumentSession` (see above) |
1044
1521
  | `Brass.mountToolbar(el, options?)` | `SessionToolbarHandle` (`{ tools, destroy }`) |
1045
1522
  | `Brass.createDocument(options?)` | `Promise<OpenedDocument>` (`session.create` drives this) |
1046
- | `Brass.openDocumentById(docId, options?)` | `Promise<OpenedDocument>` (the session's open / pick path drives this) |
1047
- | `Brass.selectDocument()` | `Promise<SelectDocumentResult \| null>` (the pick; also render from the session, see `docs/hosted-ui-advanced.md`) |
1523
+ | `Brass.openDocumentById(docId, options?)` | `Promise<OpenedDocument>` (the session's `open` drives this); `ReadOnlyDocument` for `readOnly: true` or `frozen: true` |
1524
+ | `Brass.chooseInputs(families, options?)` | `Promise<InputReference[] \| null>` (what this user has chosen for your app) |
1525
+ | `Brass.chooseInput(families, options?)` | `Promise<InputReference \| null>` (one file this user has chosen) |
1048
1526
  | `Brass.shareDocument(docId)` | `Promise<void>` |
1049
1527
  | `Brass.registerImporter(fn)` | `void` (see `docs/imported-documents.md`) |
1050
1528
  | `Brass.registerSerializer(fn)` | `void` (see `docs/imported-documents.md`) |
1051
- | `Brass.documents` | namespace getter (metadata read and thumbnails for a known doc id) |
1529
+ | `Brass.documents` | namespace getter (metadata read, thumbnails) |
1052
1530
 
1053
1531
  The composable header mounts (`mountHeader`, `mountFileMenu`,
1054
1532
  `mountDocumentTitle`, `mountUndoRedo`, `mountPresenceRoster`,
@@ -1062,21 +1540,30 @@ methods return `OpenedDocument`, so type app-level variables as
1062
1540
 
1063
1541
  ### Errors
1064
1542
 
1065
- Every call rejects with one error type, `BrassError`. HTTP calls carry
1066
- `.status: number`, and the hosted-UI launchers (`selectDocument`,
1067
- `shareDocument`, ...) carry `.code` (`'embed_error'`,
1068
- `'not_in_browser'`). A call made without a signed-in session rejects
1069
- with `.status === 401` before it reaches the network.
1543
+ Every rejecting call rejects with a `BrassError`. HTTP calls carry
1544
+ `.status: number`, and hosted-UI launchers and API operations provide an actionable `.code`:
1545
+
1546
+ | Code | Where it occurs | What to do |
1547
+ | ---- | --------------- | ---------- |
1548
+ | `'embed_error'` | Hosted UI dialogs (`session.open()`, `shareDocument()`) | The dialog failed to load or communicate. Display `error.message`. |
1549
+ | `'not_in_browser'` | Hosted UI dialogs | Called in an environment without DOM/browser globals. Run only in browser contexts. |
1550
+ | `'iframe_unavailable'` | `chooseInputs()`, `chooseInput()` | The browser blocked the picker iframe (e.g. strict CSP or partitioned storage). Display `error.message` and keep existing references. |
1551
+ | `'app_not_granted'` | Opening a document via ID | Your app was not granted access to this document. Have the user pick the document through `session.open()` to grant access. |
1552
+ | `'not_imported'` | Opening a file whose type another app imports | The import runs only when someone opens the file in that app. Show `error.message` rather than a loading state, since nothing is in progress. In a `subscribeInputDocuments` handler, show it and rethrow, and Brass re-invokes the handler once that app has imported the file. |
1553
+
1554
+ Calls made without an active session reject with `status === 401` locally before
1555
+ reaching the network. Reopening an editable copy on a document where the user only has
1556
+ view access rejects with `status === 403`.
1070
1557
 
1071
1558
  ## Guides
1072
1559
 
1073
- - [`AGENTS.md`](./AGENTS.md): instructions for AI coding agents, covering the review loop, the UX defaults a generated app should meet, and shipping it to a published URL.
1560
+ - [`AGENTS.md`](./AGENTS.md): instructions for AI coding agents, covering driving the sign-in, the review loop, the UX defaults a generated app should meet, and shipping it to a published URL.
1074
1561
  - [`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.
1075
1562
  - [`docs/imported-documents.md`](./docs/imported-documents.md): the record store an imported file becomes, `Brass.registerImporter`, `doc.sourceBlob()`, and `Brass.registerSerializer`.
1076
1563
  - [`docs/schemas.md`](./docs/schemas.md): declaring a schema, creating a stream, what lets an app open a document, and what breaks that.
1077
- - [`docs/streams.md`](./docs/streams.md): partitioning a document for lazy loading, per-stream undo, and reading a document spread across streams.
1564
+ - [`docs/streams.md`](./docs/streams.md): splitting a document into streams that load independently, opening one, adding one, and reading a document spread across streams.
1078
1565
  - [`docs/custom-chrome.md`](./docs/custom-chrome.md): composable header pieces, and rendering `session.commands` yourself for a fully custom shell.
1079
1566
  - [`docs/text-fields.md`](./docs/text-fields.md): binding a field to `doc.text(key)`, painting a peer's edit without losing the caret, the IME window, what a keystroke costs, and where a text container belongs in a document.
1080
1567
  - [`docs/services.md`](./docs/services.md): calling a declared outside service, every refusal code, values only the user knows, and a service split across addresses.
1081
1568
  - [`docs/presence.md`](./docs/presence.md): remote cursors, roster rendering, coordinate conventions, full types.
1082
- - [`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.
1569
+ - [`docs/hosted-ui-advanced.md`](./docs/hosted-ui-advanced.md): the hosted launchers (`Brass.session.open()`, `Brass.shareDocument`), their redirect behavior in browsers that block the iframe, theming, CSP.