@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
package/README.md ADDED
@@ -0,0 +1,836 @@
1
+ # @brass-build/client
2
+
3
+ Client SDK for [Brass](https://brass.build), a platform for building
4
+ and distributing enterprise-ready software. Brass closes the gap
5
+ between prototypes and production by ensuring that arbitrary, often
6
+ agent-coded software meets expectations for security, scalability,
7
+ robustness, and capability. Applications built on Brass inherit
8
+ foundational capabilities such as authentication, access control, and a
9
+ durable, collaborative data layer rather than reimplementing them.
10
+
11
+ Brass is fundamentally unopinionated, supporting any product
12
+ manifestation, data model, or sibling technologies. However, it is
13
+ particularly well-suited for software in the Architecture, Engineering,
14
+ and Construction (AEC) space, given its support for collaboration on
15
+ large geometric models and out-of-the-box integration into popular AEC
16
+ products.
17
+
18
+ ```sh
19
+ npm install @brass-build/client
20
+ ```
21
+
22
+ ## Contents
23
+
24
+ - [Quickstart](#quickstart)
25
+ - [Setup](#setup)
26
+ - [Rendering auth state](#rendering-auth-state)
27
+ - [Default UI](#default-ui)
28
+ - [Document lifecycle](#document-lifecycle)
29
+ - [Editing documents](#editing-documents)
30
+ - [Presence](#presence-live-cursors-and-whos-here)
31
+ - [Streams](#streams-partitioning-large-documents)
32
+ - [Imported files](#imported-files)
33
+ - [Reference](#reference)
34
+ - [Guides](#guides)
35
+
36
+ ## Quickstart
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:
49
+
50
+ ```
51
+ Build a shared checklist on @brass-build/client (NPM).
52
+ ```
53
+
54
+ **Or wire it by hand.** The Brass surface of an app is small:
55
+
56
+ ```ts
57
+ import { Brass } from '@brass-build/client';
58
+ import type { BrassUser, Document } from '@brass-build/client';
59
+
60
+ // Register listeners before init so validation failures reach onError.
61
+ Brass.onError((err) => console.error('[Brass]', err.message));
62
+
63
+ // Swap your two shells as auth settles. Both start hidden, so nothing
64
+ // paints before the first onUserChange fire.
65
+ Brass.onUserChange((user: BrassUser | null) => {
66
+ document.getElementById('signed-out')!.hidden = !!user;
67
+ document.getElementById('signed-in')!.hidden = !user;
68
+ });
69
+ document.getElementById('sign-in')!.onclick = () => Brass.signIn();
70
+
71
+ // The whole file menu in one call: document title, undo / redo,
72
+ // account menu, presence roster.
73
+ Brass.mountToolbar(document.getElementById('app-bar')!);
74
+
75
+ // Attach your editor to each document the session opens. Compare the
76
+ // Document instance, never the id (a version preview reuses the id).
77
+ let bound: Document | null = null;
78
+ Brass.session.subscribe((session) => {
79
+ if (session.document !== bound) {
80
+ bound = session.document;
81
+ if (bound) bindEditor(bound); // see Editing documents
82
+ }
83
+ });
84
+
85
+ await Brass.init('brass_app_...');
86
+ ```
87
+
88
+ `bindEditor` is your code: take a handle for the data you render
89
+ (`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.
92
+ [Document lifecycle](#document-lifecycle) and
93
+ [Editing documents](#editing-documents) show the loop in full.
94
+
95
+ Set up your bundler for the SDK's WebAssembly module
96
+ ([Bundler setup](#bundler-setup)), then build and publish:
97
+
98
+ ```sh
99
+ npm run build
100
+ npx @brass-build/cli login # opens your browser to sign in
101
+ npx @brass-build/cli publish ./dist
102
+ ```
103
+
104
+ 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.
107
+
108
+ ## Setup
109
+
110
+ To take the [quickstart](#quickstart) from source to a
111
+ signed-in, hosted app:
112
+
113
+ 1. **Set up the bundler.** The SDK ships a WebAssembly module, and your
114
+ bundler needs to know how to load it; see
115
+ [Bundler setup](#bundler-setup).
116
+ 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.
126
+
127
+ **CSP.** If (and only if) your app sends a `Content-Security-Policy`,
128
+ extend it: `script-src` needs `'wasm-unsafe-eval'` for the SDK's
129
+ WebAssembly, `frame-src` needs `https://app.brass.build` for the hosted
130
+ picker and share dialog, and `img-src` needs
131
+ `https://*.s3.<region>.amazonaws.com` if you render document thumbnails
132
+ (they are presigned S3 URLs).
133
+
134
+ ### Bundler setup
135
+
136
+ `@brass-build/client` ships as ES modules, loaded by a bundler (Vite,
137
+ webpack, esbuild) or Node's ESM loader with `import`.
138
+
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:
142
+
143
+ ```sh
144
+ npm install --save-dev vite-plugin-wasm vite-plugin-top-level-await
145
+ ```
146
+
147
+ ```ts
148
+ // vite.config.ts
149
+ import { defineConfig } from 'vite';
150
+ import wasm from 'vite-plugin-wasm';
151
+ import topLevelAwait from 'vite-plugin-top-level-await';
152
+ import { brassVite } from '@brass-build/client/vite';
153
+
154
+ export default defineConfig({
155
+ plugins: [wasm(), topLevelAwait(), brassVite()],
156
+ build: { target: 'es2022' },
157
+ });
158
+ ```
159
+
160
+ `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).
166
+
167
+ ### Init
168
+
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:
174
+
175
+ | Init option | Default |
176
+ | ----------- | ------- |
177
+ | `hostedUiTheme` | `'light'` (also `'dark'`, `'auto'`) |
178
+ | `syncDocToUrl` | `true` (sync the active selection to the URL + browser history) |
179
+ | `docUrlParam` | `'doc'` (the query param for the active doc id) |
180
+ | `branchUrlParam` | `'branch'` (the query param for a non-default branch) |
181
+ | `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) |
183
+
184
+ `Brass.init` resolves after one auth round-trip. Register `onError`
185
+ and `onUserChange` BEFORE calling `init`, so that validation failures
186
+ fire through your listener instead of dying as unhandled rejections; a
187
+ bad `app_id` still resolves, with `Brass.user === null`.
188
+
189
+ ### Ship it
190
+
191
+ Publishing deploys your built bundle to Brass hosting at
192
+ `https://<slug>.onbrass.app` (or whichever domain you've configured), a
193
+ content origin Brass serves and adds to the app's sign-in allowlist for
194
+ you. Run it with the `brass` CLI:
195
+
196
+ ```sh
197
+ npm run build
198
+ npx @brass-build/cli publish ./dist
199
+ ```
200
+
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.
204
+
205
+ Run `brass login` once first to authenticate (it opens your browser),
206
+ or set `BRASS_SERVICE_TOKEN` in a pipeline. The CLI also pulls a
207
+ document's schema into your `brass-app.json`. See
208
+ [`@brass-build/cli`](https://www.npmjs.com/package/@brass-build/cli).
209
+
210
+ ## Rendering auth state
211
+
212
+ Auth has **four** states: `Brass.authStatus` is
213
+ `'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'`.
221
+
222
+ `'guest'` is the "anyone with the link" case: someone opened a public
223
+ view link, so there is no signed-in user but `Brass.session` still
224
+ opens the shared document **read-only** for them (`Brass.isGuest` is
225
+ `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).
229
+
230
+ `Brass.onUserChange` is the single source for reacting to auth
231
+ changes. It fires once when `init` resolves (with the real user or
232
+ `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.
235
+
236
+ The plain-DOM sample in the [quickstart](#quickstart) gets this
237
+ right with no extra code, because both shells start `hidden` and
238
+ nothing shows until the first `onUserChange`. A framework app (React, Vue, Svelte) paints its
239
+ initial state synchronously, before that first fire, so it renders the
240
+ three states explicitly:
241
+
242
+ ```ts
243
+ // Vue
244
+ const authStatus = ref(Brass.authStatus);
245
+ Brass.onUserChange(() => { authStatus.value = Brass.authStatus; });
246
+ ```
247
+
248
+ ```html
249
+ <SplashOrNothing v-if="authStatus === 'unknown'" />
250
+ <SignInCard v-else-if="authStatus === 'signed-out'" />
251
+ <AppShell v-else /> <!-- signed-in or guest (read-only) -->
252
+ ```
253
+
254
+ ## Default UI
255
+
256
+ `Brass.mountToolbar(container)` fills a container with the full
257
+ document toolbar, wired to the session: creating, opening, renaming,
258
+ 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:
264
+
265
+ ```ts
266
+ const bar = Brass.mountToolbar(document.getElementById('app-bar')!);
267
+ bar.tools.append(orbitButton, drawButton);
268
+ ```
269
+
270
+ The widget renders its own full-width, mobile-responsive bar, so give
271
+ it a full-width block container and let it own that row. The common
272
+ shell is a column flex with the bar on top and the working surface
273
+ filling the rest:
274
+
275
+ ```html
276
+ <div style="display:flex; flex-direction:column; height:100vh">
277
+ <div id="app-bar"></div> <!-- mountToolbar fills this -->
278
+ <div id="stage" style="flex:1"></div>
279
+ </div>
280
+ ```
281
+
282
+ **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.
298
+
299
+ ## Document lifecycle
300
+
301
+ `Brass.session` tracks which document is open. It restores the URL's
302
+ selection on boot, opens and closes documents, keeps the URL, browser
303
+ history, and page title in step with the active document (so Back /
304
+ Forward and reload behave as expected), and exposes the file-menu
305
+ commands as data, which `mountToolbar` renders for you. Your app
306
+ consumes all of this through one subscription:
307
+
308
+ ```ts
309
+ Brass.session.subscribe((session) => {
310
+ renderStatus(session.status);
311
+ if (session.document !== bound) {
312
+ bound = session.document;
313
+ if (bound) bindEditor(bound);
314
+ }
315
+ });
316
+ ```
317
+
318
+ `session.subscribe` fires on lifecycle changes (open, close, switch,
319
+ 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).
323
+
324
+ `session.status` tells you which of six screens to render:
325
+
326
+ | `status` | Meaning | Render |
327
+ | ------------ | ---------------------------------------- | ------------------------------------ |
328
+ | `unknown` | `init` not settled; auth not yet known. | Nothing, or a splash. |
329
+ | `signed-out` | No signed-in user. | The sign-in CTA. |
330
+ | `empty` | Signed in, no document open. | "No document open. Use New or Open." |
331
+ | `loading` | An open / create is in flight. | A spinner or progress bar (below). |
332
+ | `ready` | A document is open (`session.document`). | Your editor. |
333
+ | `error` | A document couldn't be opened, or the open one was deleted.| `session.error.message` + your recovery actions (below) |
334
+
335
+ Gate edit affordances on `ready`: a canvas that accepts input in the
336
+ `empty` state lets the user produce work with nowhere to save it.
337
+
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:
343
+
344
+ ```ts
345
+ if (session.status === 'error') {
346
+ showMessage(session.error.message); // render verbatim; don't write your own
347
+ // ...alongside your app's own New / Open buttons.
348
+ }
349
+ ```
350
+
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
356
+ document you were editing and the `open()` call rejects, so catch that
357
+ at the call site instead.
358
+
359
+ **Loading progress.** While `status === 'loading'`, the session
360
+ exposes `session.loadingMessage` (a ready-to-render string such as
361
+ `Downloading... 62%`; show it verbatim) and `session.loadingProgress`
362
+ (a `0..1` fraction when the size is known, or `null` for
363
+ indeterminate). The document binds only after its content downloads,
364
+ so the first `ready` render already has the real model to read
365
+ directly.
366
+
367
+ **Where new documents land.** The `new` command (and
368
+ `session.create()`) places the document for you: a sibling of the open
369
+ document, or a platform-chosen home from the empty state.
370
+ `session.create()` resolves `null` if the user cancels placement:
371
+
372
+ ```ts
373
+ await Brass.session.create({ name: 'Scheme B' });
374
+ ```
375
+
376
+ **Keyboard shortcuts** route through `session.run`, which drives the
377
+ same undo / redo commands the toolbar buttons use. `session.commands`
378
+ carries every command with its id, and
379
+ [`docs/custom-chrome.md`](./docs/custom-chrome.md) tables the built-in
380
+ set if you need to invoke one directly:
381
+
382
+ ```ts
383
+ document.addEventListener('keydown', (e) => {
384
+ if (!(e.ctrlKey || e.metaKey)) return;
385
+ const key = e.key.toLowerCase();
386
+ if (key === 'z' && !e.shiftKey) {
387
+ e.preventDefault();
388
+ void Brass.session.run('undo');
389
+ } else if ((key === 'z' && e.shiftKey) || key === 'y') {
390
+ e.preventDefault();
391
+ void Brass.session.run('redo');
392
+ }
393
+ });
394
+ ```
395
+
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.
400
+
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.
404
+
405
+ ## Editing documents
406
+
407
+ ### Bind the editor to the document
408
+
409
+ When the session opens a document, attach your editor to it: take a
410
+ handle for each container you render, and subscribe. The subscription
411
+ in [Document lifecycle](#document-lifecycle) does this in `bindEditor`,
412
+ called whenever `s.document` changes:
413
+
414
+ ```ts
415
+ function bindEditor(doc: Document): void {
416
+ const walls = doc.map<Wall>('walls');
417
+ // One subscribe line is the whole render loop; the first fire
418
+ // carries the current state, so there is no separate initial render.
419
+ const stop = walls.subscribe(() => renderWalls(walls.read()));
420
+ doc.onGone(() => stop());
421
+ }
422
+ ```
423
+
424
+ Compare the **`Document` instance**, never `document.id`. The session
425
+ hands you a fresh instance on every open, and a version preview is a
426
+ new instance with the same id, so an id comparison would skip the
427
+ rebind and strand your editor on the previous state.
428
+
429
+ ### Read and mutate
430
+
431
+ The primary editing surface is a **typed container handle**:
432
+ `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)
434
+ that container. A large document stays cheap because your editor
435
+ touches the slice it cares about instead of walking the whole
436
+ document.
437
+
438
+ ```ts
439
+ type Wall = { type: 'wall'; x: number; z: number };
440
+
441
+ const walls = doc.map<Wall>('walls');
442
+
443
+ walls.mutate((m) => { m[id] = { type: 'wall', x: 0, z: 0 }; }); // add
444
+ walls.mutate((m) => { m[id].x = 42; }); // field edit
445
+ walls.mutate((m) => { delete m[id]; }); // remove
446
+
447
+ const all = walls.read(); // Record<string, Wall>
448
+ const one = walls.get(id); // Wall | undefined
449
+ ```
450
+
451
+ Handles are cached per key (`doc.map('walls') === doc.map('walls')`),
452
+ so they compare stable across renders. `doc.list<T>(key)` is the same
453
+ surface for a root list; its mutate draft is a plain array proxy, and
454
+ indexed access returns a live element, so find an index then edit it in
455
+ place:
456
+
457
+ ```ts
458
+ doc.list<Col>('cols').mutate((l) => {
459
+ const i = l.findIndex((c) => c.id === colId);
460
+ l[i].name = name; // or l[i] = { ...l[i], name }, or l.splice(i, 1)
461
+ });
462
+ ```
463
+
464
+ For a keyed collection that has to **stay in a stable order** (a list
465
+ of notes, layers, or steps), use `doc.orderedMap<T>(mapKey, orderKey)`
466
+ rather than a plain map with an app-computed sort key. It binds the map
467
+ to a sibling order list and keeps them in lockstep: a new key appends in
468
+ 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()`.
473
+
474
+ ```ts
475
+ const notes = doc.orderedMap<Note>('notes', 'note_order');
476
+ notes.mutate((m) => { m[id] = { text: '' }; }); // appends to the order
477
+ notes.mutate((m) => { m[id].text = 'hi'; }); // field edit; order untouched
478
+ for (const { key, value } of notes.ordered()) renderRow(key, value);
479
+ ```
480
+
481
+ A field edit leaves the order alone, so its `subscribe` fire carries
482
+ only `set` / `deleted`; a change that moves the order also carries
483
+ `change.order` (the live keys in order). Re-sequence your rows when
484
+ `change.order` is present and patch in place otherwise, so a field echo
485
+ never disturbs the row a user is editing.
486
+
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.
491
+
492
+ Two mutation rules:
493
+
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.
499
+ - **Don't write `undefined`.** Mutations JSON-serialise, so
500
+ `undefined` inside an array becomes `null` and `undefined` property
501
+ values are dropped. Use `null`, length checks, or `'key' in obj`
502
+ instead.
503
+
504
+ A document starts **empty**, and the user's first action is their
505
+ 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.
509
+
510
+ ### Subscribing
511
+
512
+ `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.
520
+
521
+ ```ts
522
+ const stop = walls.subscribe((change) => {
523
+ for (const [id, wall] of change.set) reconcileWall(id, wall); // added or updated
524
+ for (const id of change.deleted) removeWall(id); // removed
525
+ });
526
+ ```
527
+
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()))`).
555
+ An `orderedMap` fire also carries `change.order` (the live ids in
556
+ 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.
560
+
561
+ `doc.subscribe(cb)` is the catch-all for events not tied to one
562
+ container (saving-count ticks, branch-list changes). Use it for status
563
+ surfaces, and render the editor from handles rather than from a
564
+ `doc.subscribe(() => renderAll())` that redraws on every tick.
565
+
566
+ ### Undo, checkpoints, and version history
567
+
568
+ The `undo` and `redo` commands in the session's command list are fully
569
+ wired: the toolbar renders them, and keyboard shortcuts reach them
570
+ through `session.run`. The undo stack is local-peer-scoped, so
571
+ `undo()` only reverts commits this client made and a collaborator's
572
+ edits stay untouched. Outside the session, call `doc.undo()` /
573
+ `doc.redo()` directly and read the enabled state via
574
+ `doc.subscribeUndoState(cb)`.
575
+
576
+ A **checkpoint** (`doc.createCheckpoint()`) is both the Ctrl-Z
577
+ 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:
582
+
583
+ | Your app shape | What to do | Result |
584
+ | --------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
585
+ | Default / no opinion | Nothing. | Auto-seal every 60s of idle. |
586
+ | Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`. | One drag = one Ctrl-Z. |
587
+ | Text editor / fast bursts | `doc.setAutoCheckpointIntervalMs(2000)` at boot. | Each typing pause becomes a boundary. |
588
+ | Explicit-only | `doc.setAutoCheckpointIntervalMs(0)` | Only deliberate `createCheckpoint` calls. |
589
+
590
+ `createCheckpoint()` can reject on a rate limit, and a missed seal is
591
+ harmless, so call it fire-and-forget.
592
+
593
+ The SDK renders version preview itself: while an earlier version is
594
+ showing, `mountToolbar` / `mountHeader` show the "viewing an earlier
595
+ version" banner and return-to-live control (mount `mountVersionPreview`
596
+ 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.
600
+
601
+ ### Reacting to deletes
602
+
603
+ `doc.onGone(cb)` fires once when the server says the document is no
604
+ longer available (someone deleted it through the share dialog, for
605
+ example). The session reacts on its own, returning to the empty state
606
+ on the user's own delete or the `error` state when someone else deletes
607
+ the open document, so this hook exists for your app's own cleanup:
608
+
609
+ ```ts
610
+ doc.onGone((reason) => {
611
+ showToast(`"${docName}" was ${reason}.`);
612
+ teardownEditor();
613
+ });
614
+ ```
615
+
616
+ ### Thumbnails
617
+
618
+ For a canvas app, one `useCanvas` call gives every document a live
619
+ thumbnail on its dashboard card:
620
+
621
+ ```ts
622
+ Brass.session.useCanvas(() => document.querySelector('canvas'));
623
+ ```
624
+
625
+ The SDK debounces about a second after each local edit, screenshots
626
+ the canvas, downscales, and uploads. **WebGL canvases must set
627
+ `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.
633
+
634
+ ## Presence (live cursors and who's here)
635
+
636
+ `doc.presence` shows who else is in the document and what they are
637
+ doing: a roster of the other live participants plus a small per-user
638
+ `state` (cursor, camera, selection). It is non-null on any open
639
+ writable document, and it is fully ephemeral; nothing survives a
640
+ disconnect. `Brass.session.presence` is the same client for the
641
+ session's open document, so use whichever handle you have.
642
+
643
+ Presence requires publishing your own state and rendering everyone
644
+ else's. Publish it on every pointer, camera, or selection move
645
+ (throttling is internal, about 10 Hz, so call as often as you like),
646
+ and render theirs in the working surface. A roster of initials in the
647
+ header shows who is present, not where they are working.
648
+
649
+ ```ts
650
+ canvas.addEventListener('pointermove', (e) => {
651
+ const p = pointerToWorld(e); // your app's screen-to-world unproject
652
+ Brass.session.presence?.set({ state: { cursor: { x: p.x, y: p.y, z: p.z } } });
653
+ });
654
+ ```
655
+
656
+ `cursor` is a 3D point in document world space (meters, right-handed,
657
+ 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).
663
+
664
+ ## Streams (partitioning large documents)
665
+
666
+ You can split a document into named streams, each an independently
667
+ loaded unit of its state. Opening a document costs only the
668
+ streams your app touches, so a large design partitioned by discipline
669
+ opens at the size of one discipline, not the whole building:
670
+
671
+ ```ts
672
+ const hvac = await doc.stream('hvac'); // downloads + subscribes hvac only
673
+ const records = hvac.orderedMap('instances', 'instance_order');
674
+ records.mutate((m) => {
675
+ m[id] = { type, params };
676
+ });
677
+ ```
678
+
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.
690
+ A stream costs one extra round trip, so add one when it carries real
691
+ content: partition by load unit (a discipline, a heavy layer), not by
692
+ concept.
693
+
694
+ A schema names the streams it expects under `streams`. An app that
695
+ follows references between records reads every stream the document holds
696
+ (`doc.listStreams()`). A document can hold streams your schema does not
697
+ name, and your app leaves those alone. Naming a stream is how records
698
+ you work with (clash findings, review notes, a cache) ride in the
699
+ document: they sync and version with it, and an exporter composes only
700
+ the streams it declares, so records in a stream it does not name stay
701
+ out of the exported file. See [`docs/streams.md`](./docs/streams.md) and
702
+ [`docs/schemas.md`](./docs/schemas.md).
703
+
704
+ ## Imported files
705
+
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.
712
+
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:
715
+
716
+ - **It holds records under a schema.** An importer for the format
717
+ already exists (an installed app provides it). Adopt the document's
718
+ schema (below) and read and edit the records through the ordinary
719
+ handle surface
720
+ (`doc.orderedMap('instances', 'instance_order')`). Brass re-emits the
721
+ original file on export and drive-push through an installed app that
722
+ serialises the format. A large
723
+ document may spread its records across several streams; if your app
724
+ follows references between records, read every stream the document
725
+ holds, see
726
+ [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.
731
+
732
+ ```ts
733
+ Brass.registerImporter(async (src, doc) => {
734
+ const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
735
+ const records = doc.orderedMap('instances', 'instance_order');
736
+ records.mutate((m) => {
737
+ for (const r of parseMyFormat(bytes)) m[r.id] = { type: r.type, params: r.body };
738
+ });
739
+ });
740
+ Brass.registerSerializer((doc) => serializeMyFormat(doc));
741
+ ```
742
+
743
+ Brass runs the importer when the file needs importing, and the
744
+ records reach every open client through its normal subscription.
745
+ Declare the `schema` of the records your importer writes in
746
+ `brass-app.json`; importing publishes that schema and stamps it on
747
+ the document, so the next app can adopt it like any imported
748
+ document's. Declare the file match there too
749
+ (`"accepts": [{ "extension": "gpx" }]`) so Brass offers your app when
750
+ someone uploads one.
751
+
752
+ To make your app open documents of an existing file type, adopt that
753
+ 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.
765
+
766
+ Every app ships a capability manifest at `/.well-known/brass-app.json`
767
+ 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
769
+ 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.
772
+ [`docs/app-manifest.md`](./docs/app-manifest.md) covers where the file
773
+ goes, what each key does, and how to confirm your build carries it.
774
+
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).
778
+
779
+ ## Reference
780
+
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.
786
+
787
+ ### `Brass`
788
+
789
+ | Member | Returns |
790
+ | ------------------------------------------------------------ | --------------------------------------- |
791
+ | `Brass.init(appId, options?)` | `Promise<void>` |
792
+ | `Brass.signIn()` | `void` (navigates page) |
793
+ | `Brass.signOut()` | `void` (navigates page) |
794
+ | `Brass.user` | `BrassUser \| null` |
795
+ | `Brass.authStatus` | `'unknown' \| 'signed-in' \| 'signed-out' \| 'guest'` |
796
+ | `Brass.isGuest` | `boolean` (anonymous link viewer) |
797
+ | `Brass.onUserChange(cb)` | `() => void` (unsubscribe) |
798
+ | `Brass.onError(cb)` | `() => void` (unsubscribe) |
799
+ | `Brass.getAccessToken()` | `Promise<string \| null>` |
800
+ | `Brass.session` | `DocumentSession` (see above) |
801
+ | `Brass.mountToolbar(el, options?)` | `SessionToolbarHandle` (`{ tools, destroy }`) |
802
+ | `Brass.createDocument(options?)` | `Promise<OpenedDocument>` (`session.create` drives this) |
803
+ | `Brass.openDocumentById(docId, options?)` | `Promise<OpenedDocument>` (the session's open / pick path drives this) |
804
+ | `Brass.selectDocument()` | `Promise<SelectDocumentResult \| null>` (the pick; also render from the session, see `docs/hosted-ui-advanced.md`) |
805
+ | `Brass.shareDocument(docId)` | `Promise<void>` |
806
+ | `Brass.registerImporter(fn)` | `void` (see `docs/imported-documents.md`) |
807
+ | `Brass.registerSerializer(fn)` | `void` (see `docs/imported-documents.md`) |
808
+ | `Brass.documents` | namespace getter (metadata read, thumbnails, branches for a known doc id) |
809
+
810
+ The composable header mounts (`mountHeader`, `mountFileMenu`,
811
+ `mountDocumentTitle`, `mountUndoRedo`, `mountPresenceRoster`,
812
+ `mountDriveSync`, `mountAccountMenu`, `mountVersionPreview`) live in
813
+ [`docs/custom-chrome.md`](./docs/custom-chrome.md).
814
+
815
+ `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
818
+ `{ sub: string; email?: string; display_name?: string; avatar_url?: string }`.
819
+
820
+ ### Errors
821
+
822
+ Every call rejects with one error type, `BrassError`. HTTP calls carry
823
+ `.status: number`, and the hosted-UI launchers (`selectDocument`,
824
+ `shareDocument`, ...) carry `.code` (`'embed_error'`,
825
+ `'not_in_browser'`). A call made without a signed-in session rejects
826
+ synchronously with `.status === 401`.
827
+
828
+ ## Guides
829
+
830
+ - [`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.
831
+ - [`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
+ - [`docs/imported-documents.md`](./docs/imported-documents.md): the record store an imported file becomes, `Brass.registerImporter`, `doc.sourceBlob()`, and `Brass.registerSerializer`.
833
+ - [`docs/schemas.md`](./docs/schemas.md): declaring a schema, creating a stream, what lets an app open a document, and what breaks that.
834
+ - [`docs/custom-chrome.md`](./docs/custom-chrome.md): composable header pieces, and rendering `session.commands` yourself for a fully custom shell.
835
+ - [`docs/presence.md`](./docs/presence.md): remote cursors, roster rendering, coordinate conventions, full types.
836
+ - [`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.