@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
@@ -0,0 +1,40 @@
1
+ // The ceilings an op can hit, measured before it is sent.
2
+ //
3
+ // Duplicated from the server's `frameExceedsWsLimit` / `op-blob.ts`
4
+ // rather than imported: this package ships unbundled and can only import
5
+ // its own relative files. The server owns the contract; these follow it.
6
+ // The transport does not deliver a message above this. It closes the
7
+ // connection instead, taking down everything else multiplexed onto the
8
+ // socket, so an oversized frame must never be sent.
9
+ const WS_FRAME_MAX_BYTES = 128 * 1024;
10
+ // What one stored record holds inline. Past it the blob is uploaded
11
+ // straight to storage rather than carried in a request body, so this is
12
+ // where the write path changes shape, not where it stops.
13
+ const OP_ROW_MAX_BLOB_CHARS = 350 * 1024;
14
+ // The largest op the platform accepts. A single value past this cannot
15
+ // be split (a record is the merge unit, so one assignment is the
16
+ // smallest thing a commit carries), which is why the refusal points at
17
+ // storing the bulk as a file instead.
18
+ const MAX_OP_BLOB_CHARS = 8 * 1024 * 1024;
19
+ /**
20
+ * True when the serialized frame would exceed what the socket carries.
21
+ *
22
+ * The envelope is measured with the blob emptied, so a multi-megabyte
23
+ * value is never serialized just to be counted. Every byte is ASCII
24
+ * (base64 blob, topic, id), so length is byte length.
25
+ */
26
+ export function opFrameExceedsWsLimit(frame) {
27
+ const envelopeChars = JSON.stringify({ ...frame, op_blob: '' }).length;
28
+ return envelopeChars + frame.op_blob.length > WS_FRAME_MAX_BYTES;
29
+ }
30
+ /** True when the blob is too large to ride in a request body, so it is
31
+ * uploaded to storage first and the write names the staged object. */
32
+ export function opBlobNeedsStaging(opBlobB64) {
33
+ return opBlobB64.length > OP_ROW_MAX_BLOB_CHARS;
34
+ }
35
+ /** True when no single op can hold the blob, so the server could only
36
+ * refuse it. Checked locally to save an upload that ends in a 400. */
37
+ export function opBlobExceedsDurableLimit(opBlobB64) {
38
+ return opBlobB64.length > MAX_OP_BLOB_CHARS;
39
+ }
40
+ //# sourceMappingURL=ws-frame-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws-frame-limit.js","sourceRoot":"","sources":["../src/ws-frame-limit.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,EAAE;AACF,oEAAoE;AACpE,yEAAyE;AACzE,yEAAyE;AAEzE,qEAAqE;AACrE,uEAAuE;AACvE,oDAAoD;AACpD,MAAM,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAEtC,oEAAoE;AACpE,wEAAwE;AACxE,0DAA0D;AAC1D,MAAM,qBAAqB,GAAG,GAAG,GAAG,IAAI,CAAC;AAEzC,uEAAuE;AACvE,iEAAiE;AACjE,uEAAuE;AACvE,sCAAsC;AACtC,MAAM,iBAAiB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAS1C;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CAAC,KAAc;IAClD,MAAM,aAAa,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC;IACvE,OAAO,aAAa,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,kBAAkB,CAAC;AACnE,CAAC;AAED;uEACuE;AACvE,MAAM,UAAU,kBAAkB,CAAC,SAAiB;IAClD,OAAO,SAAS,CAAC,MAAM,GAAG,qBAAqB,CAAC;AAClD,CAAC;AAED;uEACuE;AACvE,MAAM,UAAU,yBAAyB,CAAC,SAAiB;IACzD,OAAO,SAAS,CAAC,MAAM,GAAG,iBAAiB,CAAC;AAC9C,CAAC"}
@@ -0,0 +1,65 @@
1
+ # The capability manifest (`brass-app.json`)
2
+
3
+ Every app ships a `brass-app.json`, and the platform reads it to decide
4
+ where your app shows up. Brass adds an "Open in" link to a document
5
+ card, offers your app when someone uploads a file it handles, and shows
6
+ a download button on the documents your app writes out.
7
+
8
+ ## Where it goes
9
+
10
+ The manifest is a file called `brass-app.json`. It must be placed in a
11
+ directory called `/.well-known/` at your bundle root, next to
12
+ `index.html`. Some bundlers skip dot-directories when they copy static
13
+ assets, so confirm that the manifest is actually in your `dist/`
14
+ directory after you build.
15
+
16
+ The platform re-reads the manifest after each deploy, so publishing
17
+ your bundle is what registers it. If Brass hosts your bundle
18
+ (`brass publish`), it reads the manifest from the bytes it stores, so
19
+ your capabilities register even when your load gate is on. If you serve
20
+ the bundle from your own origin, the platform fetches
21
+ `<origin>/.well-known/brass-app.json` as an anonymous client, so serve
22
+ that path publicly even when the rest of your app requires sign-in.
23
+
24
+ These tokens only drive discovery and grant no access, so you declare
25
+ them yourself.
26
+
27
+ ## What goes in it
28
+
29
+ ```json
30
+ {
31
+ "opens": ["tracker"],
32
+ "accepts": [{ "extension": "csv" }, { "content_type": "text/csv" }],
33
+ "creates": "tracker",
34
+ "exports": { "content_type": "text/csv", "file_extension": ".csv" },
35
+ "icon": "/icon.svg",
36
+ "schema": { "...": "see schemas.md" }
37
+ }
38
+ ```
39
+
40
+ Every field is optional, so ship only the ones your app uses.
41
+
42
+ - **`opens`** lists the `schema_type`s of documents your app reads.
43
+ Declaring a `schema` already folds its family in, so most apps list
44
+ nothing extra here.
45
+ - **`accepts`** matches an uploaded file by extension or content type,
46
+ so Brass offers your app to whoever uploads one. Your app then reads
47
+ the file's bytes through `sourceBlob()`.
48
+ - **`creates`** is the `schema_type` Brass stamps on the documents your
49
+ app makes, so peer apps that declare `opens` for the token can open
50
+ them. It folds into your own `opens` as well.
51
+ - **`exports`** names the file format your app writes a document out
52
+ to, and your `Brass.registerSerializer` callback produces the bytes.
53
+ Declaring it makes those documents downloadable. Declare it when your
54
+ app interoperates with a format outside Brass.
55
+ [`imported-documents.md`](./imported-documents.md) covers how an
56
+ importing app chooses that format.
57
+ - **`icon`** points at your app's mark, which the platform renders on
58
+ your app's tiles and on the hosted consent screen when someone grants
59
+ your app access. Draw a small SVG and serve it from your own bundle
60
+ (`"icon": "/icon.svg"`, resolved against your origin), or give an
61
+ absolute `https://` URL. An emoji (`"icon": "🏗️"`) also renders,
62
+ though apps in one domain tend to land on the same few.
63
+ - **`schema`** declares the record shape your app works with, whether
64
+ you invented it or adopted an existing one. See
65
+ [`schemas.md`](./schemas.md).
@@ -0,0 +1,147 @@
1
+ # Custom chrome
2
+
3
+ `Brass.mountToolbar` renders the whole document bar and is the
4
+ recommended path for an app that is one working surface under a bar.
5
+ This page covers the two custom shapes, from least to most custom: a
6
+ multi-pane app that builds its own header out of the **standard
7
+ pieces**, and a fully custom shell that renders `session.commands`
8
+ into its own DOM.
9
+
10
+ ## Composable header pieces (multi-pane apps)
11
+
12
+ A multi-pane app (a tree beside an inspector, a list beside a preview)
13
+ builds its own header for its own layout, but should not re-implement
14
+ the account menu, presence roster, title, undo / redo, or the header's
15
+ responsive layout by hand. Take the header shell with
16
+ `Brass.mountHeader`, then drop the standard pieces into its three
17
+ slots:
18
+
19
+ ```ts
20
+ // Each piece mount owns (replaces the children of) the element you
21
+ // give it, so hand each its own child within a cluster.
22
+ const slot = (parent: HTMLElement) => {
23
+ const el = document.createElement('div');
24
+ parent.append(el);
25
+ return el;
26
+ };
27
+
28
+ const header = Brass.mountHeader(document.getElementById('app-header')!);
29
+ Brass.mountFileMenu(slot(header.start)); // left: file menu + title
30
+ Brass.mountDocumentTitle(slot(header.start));
31
+ Brass.mountUndoRedo(header.tools); // middle: wraps on mobile
32
+ Brass.mountDriveSync(slot(header.end)); // right: pinned top-right
33
+ Brass.mountPresenceRoster(slot(header.end));
34
+ Brass.mountAccountMenu(slot(header.end)); // the account avatar
35
+ ```
36
+
37
+ `mountHeader` gives you the same responsive bar layout `mountToolbar`
38
+ uses, from one definition: `start` ellipsizes its title rather than
39
+ wrapping, `tools` drops to its own row on a narrow screen, and `end`
40
+ stays pinned top-right, so the account avatar never wraps below the
41
+ tools on mobile (the detail a hand-rolled flex header gets wrong).
42
+ Each piece renders one cluster wired to `Brass.session` and returns
43
+ `{ destroy(): void }`; `header.destroy()` removes the shell.
44
+
45
+ - `Brass.mountFileMenu` renders the hamburger file menu (the
46
+ `session.commands` menu surface).
47
+ - `Brass.mountDocumentTitle` renders the document title plus a
48
+ location chip; both open the hosted location editor (rename / move /
49
+ file). On a non-default branch the title carries a branch chip.
50
+ - `Brass.mountUndoRedo` renders undo / redo as prominent buttons.
51
+ - `Brass.mountPresenceRoster` renders the who's-here roster of
52
+ coloured initials. It is the receiver half only: still publish your
53
+ own presence state (see the README's "Presence").
54
+ - `Brass.mountDriveSync` renders the drive-sync badge. It renders
55
+ nothing on a non-drive doc, so it is safe to mount in any header.
56
+ - `Brass.mountAccountMenu` renders the account avatar menu (email, an
57
+ Open dashboard link, and sign out).
58
+
59
+ `mountHeader` already includes the historical-version preview banner
60
+ (the return-to-live strip shown while a document is previewing an
61
+ earlier version) above the bar, so a multi-pane app on the header shell
62
+ gets correct preview UX with no extra mount. Only a fully custom shell
63
+ (next section, no `mountHeader`) mounts it itself, with
64
+ `Brass.mountVersionPreview(el)`; like the drive badge it renders
65
+ nothing until there is something to show.
66
+
67
+ Mount the chrome once, when the user signs in, not per document: the
68
+ file menu has to exist in the "signed in, nothing open" state or a
69
+ fresh user has no New / Open (the same rule as `mountToolbar`).
70
+
71
+ ## Fully custom shells: the command list as data
72
+
73
+ The widgets are one renderer of this same command list, so nothing
74
+ below is a different surface, just a lower-level entry point into it.
75
+
76
+ `session.commands` is the SDK's built-in actions as data: the SDK owns
77
+ each command's behavior and live state. Each carries a `surface` hint
78
+ (`'menu'` for a file menu, `'toolbar'` for a prominent button) to
79
+ filter on when placing it. It's a hint, not a constraint. The
80
+ `Command` type is exported, so type your render code against it rather
81
+ than re-declaring a local interface:
82
+
83
+ ```ts
84
+ import type { Command } from '@brass-build/client';
85
+ ```
86
+
87
+ The full built-in set when a doc is bound, in list order:
88
+
89
+ | `id` | `group` | `surface` | `label` | Enabled when |
90
+ | ---------- | ---------- | --------- | ------------------ | --------------------------------------- |
91
+ | `new` | `session` | `menu` | New | Signed in. |
92
+ | `open` | `session` | `menu` | Open... | Signed in. |
93
+ | `history` | `document` | `menu` | Version history... | A document is open. |
94
+ | `branches` | `document` | `menu` | Branches... | A document is open. Has `children`. |
95
+ | `share` | `document` | `menu` | Share... / Share / Download... | A document is open. |
96
+ | `manage` | `document` | `menu` | Manage... | A document is open. |
97
+ | `undo` | `edit` | `toolbar` | Undo | The document is editable and there is something to undo. |
98
+ | `redo` | `edit` | `toolbar` | Redo | The document is editable and there is something to redo. |
99
+
100
+ The order runs use, then manage, then destructive: `new` / `open` put a
101
+ different document on screen, `history` / `branches` put a different
102
+ state of this one there, `share` hands the document to a person or to a
103
+ file, and `manage` (the entry holding the delete) comes last. Render the
104
+ list in the order you receive it.
105
+
106
+ Each command carries `enabled` (read on every notification),
107
+ `running` (true while `run()` is in flight, drives the pending
108
+ affordance), and `run()` (always resolves; failures route to
109
+ `Brass.onError`, never throw).
110
+
111
+ Read `label` on every notification too. `share` relabels once the SDK
112
+ knows whether the document has a file to hand over, since downloading is
113
+ one row of the share dialog rather than a command of its own.
114
+
115
+ Draw a rule where `group` changes: `'session'` (a different document),
116
+ `'document'` (this document), `'edit'` (undo / redo). The widgets do
117
+ this, so a custom menu matches them by following the same rule.
118
+
119
+ A command may carry `children` (a submenu) and `selected` (the
120
+ active choice). Render a command with `children` as a submenu parent
121
+ with a disclosure chevron; render each child as a nested item,
122
+ showing a checkmark or bold style when `child.selected` is true.
123
+
124
+ Reading the list rather than hard-coding the commands you know about
125
+ means you don't miss one as the SDK adds built-ins. Filter by `surface`
126
+ to place each, and invoke one by id with `session.run('undo')` (the
127
+ hook for keyboard shortcuts).
128
+
129
+ ## What each command does by default
130
+
131
+ Reading the list and calling `run()` wires all of it; you render and
132
+ invoke, the SDK owns the behavior:
133
+
134
+ - **`history`** drives version preview end to end. During a preview
135
+ `doc.readOnly` is `true`, so gate your own edit controls on it;
136
+ returning to live is the chrome's job (the `mountVersionPreview`
137
+ banner), not yours.
138
+ - **`branches`** carries `children` (the branches, the active one
139
+ `selected: true`) plus a fork-new child. Render it as a submenu and
140
+ invoke a child's `run()`; a flat-list shell can call the parent's
141
+ `run()` to open the hosted picker instead.
142
+ - **`share`** opens the hosted share dialog: who can open the document,
143
+ and, where there is a file to hand over, the download.
144
+ - **`manage`** opens the same dialog on the document itself: its name,
145
+ where it sits, Duplicate, and Delete.
146
+ - **`undo` / `redo`** call `doc.undo()` / `doc.redo()` (see "Undo,
147
+ checkpoints, and version history" in the README).
@@ -0,0 +1,77 @@
1
+ # Hosted UI: advanced
2
+
3
+ Some surfaces are owned by Brass: you call one method and Brass renders
4
+ the UI in a modal iframe over your page, so your app never builds the
5
+ screen, fetches the data, or handles the mutation. `Brass.session`
6
+ wires the common ones (`history`, `share`, `manage`) behind the file
7
+ menu; this page covers the rest and how they behave in browsers that
8
+ block the iframe.
9
+
10
+ ## Surfaces
11
+
12
+ | Method | Returns |
13
+ | --- | --- |
14
+ | `Brass.selectDocument()` | `Promise<SelectDocumentResult \| null>`: opens the hosted document picker; resolves to the picked document, or `null` if the user dismisses it. |
15
+ | `Brass.shareDocument(docId)` | `Promise<void>`: opens the hosted share dialog for the document. |
16
+
17
+ Version history is a built-in file-menu command (`history` in
18
+ `session.commands` and the SDK toolbar's file menu), not a launcher
19
+ method; an app on `mountToolbar` or `mountFileMenu` gets it with no
20
+ code.
21
+
22
+ ## Redirects in browsers that block the iframe
23
+
24
+ Where the browser blocks the Hosted UI iframe (Safari, Firefox, Chrome
25
+ with third-party cookies off), these surfaces open as a full-page
26
+ redirect to Brass and return to your app when the user is done. The
27
+ picker returns with the chosen document opened into `Brass.session`.
28
+
29
+ So render the active document from the session (or `mountToolbar`): on
30
+ the redirect path the page reloads, so the code after `await
31
+ Brass.selectDocument()` never runs, and the pick arrives through the
32
+ session instead. The resolved value is the modal path's inline
33
+ convenience, correct whenever you receive it.
34
+
35
+ ## `BrassError` `.code` values
36
+
37
+ The hosted-UI launchers reject with `BrassError` carrying a `.code`
38
+ (rather than the `.status` the HTTP namespaces set):
39
+
40
+ - `'embed_error'`: the hosted page loaded but couldn't proceed (e.g.
41
+ the user isn't signed in to Brass in this context).
42
+ - `'not_in_browser'`: called outside a browser environment.
43
+
44
+ ## Theming
45
+
46
+ Hosted UI surfaces render in Brass's own visual language; light/dark is
47
+ the one axis you control, defaulting to `'light'`. Override in two
48
+ cases:
49
+
50
+ - **Dark-only app**: `hostedUiTheme: 'dark'`.
51
+ - **App that follows the user's OS preference**: `hostedUiTheme: 'auto'`,
52
+ and only if your own CSS also responds to
53
+ `@media (prefers-color-scheme: dark)`. Otherwise a dark-OS user gets a
54
+ dark embed inside your light app with the close button invisible over
55
+ it.
56
+
57
+ ```ts
58
+ await Brass.init('brass_app_live_...', {
59
+ hostedUiTheme: 'auto',
60
+ });
61
+ ```
62
+
63
+ ## CSP
64
+
65
+ If your app sends a `Content-Security-Policy`, it needs `frame-src
66
+ https://app.brass.build` (the README's "Setup" lists the full set). A
67
+ bare `default-src 'self'` blocks the iframe, because `frame-src` falls
68
+ back to `default-src`, and the failure is silent. Apps that send no CSP,
69
+ or no `frame-src` / `default-src`, need no change.
70
+
71
+ ## Mixing with `Brass.session`
72
+
73
+ The session's file-menu commands invoke these surfaces for you:
74
+ `open` opens the picker, `share` and `manage` open the
75
+ document-settings dialog, and `history` opens version history. Each
76
+ command's `run()` always resolves; a failure routes to `Brass.onError`
77
+ rather than throwing.
@@ -0,0 +1,261 @@
1
+ # Imported files and formats
2
+
3
+ Your app reads and edits an imported document through the ordinary
4
+ handle surface, and Brass does the importing. A user uploads a file or
5
+ opens a drive item, and the platform turns it into a document, so there
6
+ is no client import call to make. How much of the file mapping you write
7
+ yourself depends on whether the format already has an importer:
8
+
9
+ - **A format with an importer**: the parsed records ARE the document
10
+ (an installed app provides the importer). You read and edit the
11
+ records through the ordinary handle surface, and Brass re-emits the
12
+ original on export and drive-push through an installed app that
13
+ serialises the format. Your app is an **editor**: declare the
14
+ document's schema in `brass-app.json`, the contract peers evaluate
15
+ compatibility against. The schema is also the record contract: its
16
+ `description`s declare how each field is encoded, so an analysis app
17
+ derives what it computes (a tree, geometry, quantities) from the
18
+ records the schema describes. A large document spreads those records
19
+ across the streams the schema names, so read them all (see [Reading a
20
+ document spread across
21
+ streams](./streams.md#reading-a-document-spread-across-streams)).
22
+ - **Any other file**: the document is an opaque file (source
23
+ `schema_type` is `brass:file`). Your app is the importer/exporter:
24
+ it registers its parser with `Brass.registerImporter` (Brass runs it
25
+ on the stored bytes and stamps your schema on the document) and the
26
+ inverse with `Brass.registerSerializer`.
27
+
28
+ Documents your app creates itself have no source file. If your app
29
+ only makes its own documents, it reads and edits records and declares a
30
+ schema so peers can open them.
31
+
32
+ ## The record store
33
+
34
+ The record store is the same for every format. It lives at the
35
+ document root as two containers: `instances` (a map keyed by record id
36
+ `'#23'` whose values are `{ type, params }` records) and
37
+ `instance_order` (the same keys in file order, which the serializer
38
+ walks to re-emit the file). Every record in `instances` must have its
39
+ key in `instance_order`, or the serializer drops it.
40
+
41
+ **Reach for both as one handle:
42
+ `doc.orderedMap('instances', 'instance_order')`.** The ordered map
43
+ keeps the order list in step for you: an add appends the key, a delete
44
+ prunes it, both in one commit (one undo step). You never write
45
+ `instance_order` by hand.
46
+
47
+ ```ts
48
+ const records = doc.orderedMap('instances', 'instance_order');
49
+
50
+ records.read(); // { '#1': { type, params }, ... }
51
+ records.get('#1'); // one record, or undefined
52
+ records.ordered(); // [{ key, value }, ...] in file order
53
+ records.keys(); // ['#1', '#2', ...] in file order
54
+
55
+ records.mutate((m) => {
56
+ m[newKey] = { type, params }; // add: appended to order
57
+ delete m['#7']; // delete: pruned from order
58
+ m['#3'].params = [...]; // field edit: order untouched
59
+ });
60
+ ```
61
+
62
+ `type` is the format's record tag, and `params` is the record body, whose
63
+ shape is format-specific. Values read back are detached copies.
64
+
65
+ **Edit a record by reassigning its whole `params`.** The record is the
66
+ unit of change: the value you read back is a detached copy, so
67
+ mutating it element-wise does not persist. Build the new `params` and
68
+ assign it. Because edits live in the canonical store, the original
69
+ format re-emits faithfully on export.
70
+
71
+ **A newly added record lands at the end of source order.** When the
72
+ order is structural (a record must sit inside a section or a parent's
73
+ child range to re-emit validly), don't lean on physical record order:
74
+ have your serializer reconstruct the structure from the records' own
75
+ declared section / parent on the way out.
76
+
77
+ ## Cost by change: subscribe, don't re-read
78
+
79
+ An imported model is commonly hundreds of thousands of records, so a
80
+ per-change `read()` to rebuild your view is O(file) per keystroke. The
81
+ subscription hands you a `change` on every fire: the first fire
82
+ carries the whole store as `change.set` (build your view there), and
83
+ every later fire names only the changed records (one path for your own
84
+ edits, remote edits, and post-open hydration alike). Don't memoise the
85
+ derivation on a record-count fingerprint: an in-place edit keeps the
86
+ count, so the memo reads back stale. And keep view-only changes (pan,
87
+ zoom, selection) off the content paint.
88
+
89
+ ## How a format maps to records
90
+
91
+ Map every format to records by the same recipe, which is what lets
92
+ independently-built apps predict and share a format's records:
93
+
94
+ 1. **One record per element the format enumerates as its own
95
+ addressable item** (a STEP instance, an entity, a named element).
96
+ Inline only anonymous leaf scalars that have no identity of their
97
+ own. The record is the concurrent-edit unit: its whole `params`
98
+ merges last-writer-wins, so keep `params` a small plain value and
99
+ never pack several independently-edited values into one record.
100
+ 2. **`type` is the format's own class tag, verbatim**: no renaming, no
101
+ prefix.
102
+ 3. **The key is the format's own stable id, verbatim.** When the
103
+ format has no native id, synthesize one deterministically from the
104
+ record's own content, never from its position in the file (a
105
+ positional index is not stable across a re-import) and never at
106
+ random.
107
+ 4. **`params` is the object's own fields as a plain value**,
108
+ reassigned whole to edit it.
109
+ 5. **A relationship is its own record only when the format makes it an
110
+ object**, and otherwise it stays a reference field, on the **child**
111
+ pointing up to its parent. A mutable child-list on the parent is a
112
+ concurrently-edited array, so two people adding different children
113
+ collide.
114
+ 6. **Decompose only what has a model.** Named objects shred into
115
+ records; binary bulk is referenced by hash, not inlined; a file
116
+ with no object model stays an opaque `brass:file` blob.
117
+
118
+ To build against a format someone else imported, read its schema: it
119
+ declares exactly how the importer encoded each field into `params`
120
+ (value shapes, the reference convention) in its `description`s. Get it
121
+ from the document's dashboard page (**Download brass-app.json**), and
122
+ declare that copy in `brass-app.json` to route those documents to your
123
+ app. See [`schemas.md`](./schemas.md).
124
+
125
+ ## Registering your format's importer (`Brass.registerImporter`)
126
+
127
+ For a file kept as-is, register your parse half once at boot, and Brass
128
+ runs it when an import is needed:
129
+
130
+ ```ts
131
+ Brass.registerImporter(async (src, doc) => {
132
+ // src: { url, filename, content_type, byte_size, expires_in, streams }
133
+ const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
134
+ const parsed = parseMyFormat(bytes); // your own parser
135
+ const records = doc.orderedMap('instances', 'instance_order');
136
+ records.mutate((m) => {
137
+ for (const r of parsed) m[r.id] = { type: r.type, params: r.body };
138
+ });
139
+ await doc.createCheckpoint();
140
+ });
141
+ ```
142
+
143
+ The importer receives the stored source file (a short-lived download
144
+ URL plus the file's name, type, and size, with the bytes not inlined)
145
+ and the open document, parses the bytes, and writes the records
146
+ through the ordinary handles. `DocumentImporter` types the callback.
147
+
148
+ Brass decides when an import is needed and runs your importer once. A
149
+ document that already holds its records, or has no stored file, is left
150
+ alone, and the records reach every open client through its normal
151
+ subscription. Declare the `schema` of the records your
152
+ importer writes in `brass-app.json`: importing publishes it and
153
+ stamps it on the document, so the document routes and resolves its
154
+ schema like any imported document.
155
+
156
+ Sealing the write with `doc.createCheckpoint()` at the end of the
157
+ importer makes the import one undo step, so an undo reverts it whole
158
+ instead of leaving a half-deleted model. An importer that throws
159
+ surfaces through `Brass.onError`, and Brass retries the import later.
160
+
161
+ **Spread a large format across streams.** When your format is large,
162
+ keep the records a default view reads in the default stream, and move
163
+ the bulk (geometry, an index most opens skip) onto streams you create. Name those streams in your schema's `streams`, and a reader
164
+ merges them the same way it merges any format's:
165
+
166
+ ```ts
167
+ Brass.registerImporter(async (src, doc) => {
168
+ const { structured, geometry } = parseMyFormat(await bytesOf(src));
169
+ doc.orderedMap('instances', 'instance_order').mutate((m) => {
170
+ for (const r of structured) m[r.id] = { type: r.type, params: r.body };
171
+ });
172
+ // A stream your schema names, so your serializer composes it back and
173
+ // any app expecting it merges its records.
174
+ const geom = await doc.getOrCreateStream('geometry');
175
+ geom.orderedMap('instances', 'instance_order').mutate((m) => {
176
+ for (const r of geometry) m[r.id] = { type: r.type, params: r.body };
177
+ });
178
+ await doc.createCheckpoint();
179
+ });
180
+ ```
181
+
182
+ Name `geometry` under `streams` in the same schema you ship in
183
+ `brass-app.json`, so a reader can resolve its contract. A document can
184
+ also hold streams your schema does not name (a review overlay, a
185
+ computed cache), and your serializer composes only the streams it
186
+ declares, so those stay in the document and out of the file. Nothing
187
+ here is privileged: a document your importer spreads is
188
+ indistinguishable from one the platform produced, and every app that
189
+ declares the same streams reads it the same way. Your serializer
190
+ reassembles it by reading those same streams back
191
+ (`doc.stream('geometry')`).
192
+
193
+ `doc.sourceBlob()` is the same source-file read as the importer's
194
+ `src` argument, for code outside the importer that needs the original
195
+ bytes (a "view original" affordance). It rejects with a `BrassError`
196
+ for a document that has no stored original file.
197
+
198
+ A source file is a set of one or more byte streams: the primary stream
199
+ (what the filename names) plus any sidecar streams uploaded with it (a
200
+ glTF's `.bin` buffer, a shapefile's `.dbf`). `src.streams` lists every
201
+ stream, primary first, each as `{ name, url, content_type, byte_size }`
202
+ with its own download URL, so an app fetches exactly the streams it
203
+ needs, in parallel. A single-stream file has exactly one entry (it
204
+ mirrors the top-level fields), so code that iterates `streams` handles
205
+ both cases in one path:
206
+
207
+ ```ts
208
+ const byName = new Map(src.streams.map((s) => [s.name, s]));
209
+ const buffer = byName.get('scene.bin'); // undefined when not part of the set
210
+ ```
211
+
212
+ **Choose the record as the smallest thing two people edit
213
+ independently.** Concurrent edits to one record resolve
214
+ last-writer-wins on its whole `params`, so a grid of
215
+ independently-edited values is one record per cell (keyed by row and
216
+ column), not one record per row. Reconstruct rows for display and for
217
+ the serializer by walking the cell records. The same reasoning bans
218
+ nesting a collaboratively-edited array inside one record.
219
+
220
+ ## Registering your format's serializer
221
+
222
+ Your serializer rebuilds the file from your records, and the platform
223
+ runs it for the download button and for drive publish. Write it as the
224
+ inverse of your importer so that it produces the format the importer
225
+ read. Register it once at boot, either before or after `Brass.init`:
226
+
227
+ ```ts
228
+ Brass.registerSerializer((doc) => {
229
+ const records = doc.orderedMap('instances', 'instance_order');
230
+ const text = myFormatToText(records.ordered()); // inverse of your parser
231
+ return new TextEncoder().encode(text);
232
+ });
233
+ ```
234
+
235
+ The serializer takes the open `Document` and returns the bytes
236
+ (`Uint8Array`, or a promise of one). Producing the bytes is its only
237
+ job, and `DocumentSerializer` types the callback. With it registered, your
238
+ app only ever works with records, never with file I/O.
239
+
240
+ Your app exports so that software outside Brass can read its work. The
241
+ format therefore has to be one that software already opens. If your app
242
+ imports a file, write that same format back out. If your app makes its
243
+ documents from nothing, ask the people who use it which tool they need
244
+ the download to open in, and write that tool's format. A format you
245
+ invent yourself produces a file only your app can read, and apps inside
246
+ Brass already share documents through the schema you declare.
247
+
248
+ ## Declaring what your app handles
249
+
250
+ Your app declares what it reads and writes in its capability manifest
251
+ at `/.well-known/brass-app.json`. `accepts` matches the uploaded files
252
+ you import, `exports` names the format you write back out, and `schema`
253
+ declares the records you work with. See
254
+ [`app-manifest.md`](./app-manifest.md).
255
+
256
+ An editor only needs to declare `opens`, and its copy of the format's
257
+ `schema` already does that. Brass delegates a document's import and
258
+ export to an installed app that handles the format, so your app reaches
259
+ the records without writing either half. Write your own importer and
260
+ serializer if you would rather not depend on another app being
261
+ installed, or if you need a format the installed apps do not handle.