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