@brass-build/client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
@@ -73,7 +73,16 @@ export function readLastDocument(
73
73
  ): SelectDocumentResult | null {
74
74
  const storage = getStorage();
75
75
  if (!storage) return null;
76
- const raw = storage.getItem(docKey(appId));
76
+ let raw: string | null;
77
+ try {
78
+ raw = storage.getItem(docKey(appId));
79
+ } catch {
80
+ // A storage that holds a handle but refuses reads (a partitioned or
81
+ // locked-down context). Recalling the last document is a niceness, so it
82
+ // degrades to none rather than throwing out of the caller, which would
83
+ // replace the real reason a document could not be reopened.
84
+ return null;
85
+ }
77
86
  if (raw === null) return null;
78
87
  let parsed: unknown;
79
88
  try {
package/src/serializer.ts CHANGED
@@ -1,4 +1,36 @@
1
1
  import type { Document } from './documents.js';
2
+ import type { ListHandle, MapHandle } from './handle-types.js';
3
+
4
+ /**
5
+ * A container read whole.
6
+ *
7
+ * The handles an app holds do not offer this: an app is handed its records
8
+ * through `subscribe`, so a fire costs what changed rather than what the
9
+ * document holds. A serializer is the other kind of caller. It runs detached,
10
+ * with no subscription to have been fed by, and it needs the document once, so
11
+ * it is handed a view that reads whole containers instead of being told to go
12
+ * and fetch them.
13
+ */
14
+ export interface StoredMap<T = unknown> extends MapHandle<T> {
15
+ read(): Record<string, T>;
16
+ }
17
+
18
+ export interface StoredList<T = unknown> extends ListHandle<T> {
19
+ read(): T[];
20
+ }
21
+
22
+ /**
23
+ * The document as a serializer sees it: the same containers, readable whole.
24
+ *
25
+ * A `Document` satisfies this, which is what keeps a built-in codec and a
26
+ * third-party one on one surface. The difference between them is where they
27
+ * run, not what they are allowed to reach.
28
+ */
29
+ export interface StoredDocument {
30
+ readonly id: string | null;
31
+ map<T = unknown>(key: string): StoredMap<T>;
32
+ list<T = unknown>(key: string): StoredList<T>;
33
+ }
2
34
 
3
35
  // A per-app serializer that turns an app-owned (blob) document's records
4
36
  // back into the source file's bytes: the inverse of the parse the app ran
@@ -11,5 +43,5 @@ import type { Document } from './documents.js';
11
43
  // whole job. An app with no registered serializer cannot export its blob
12
44
  // documents (the platform falls back to the stale-original passthrough).
13
45
  export type DocumentSerializer = (
14
- doc: Document,
46
+ doc: StoredDocument & Document,
15
47
  ) => Uint8Array | Promise<Uint8Array>;
@@ -1,13 +1,14 @@
1
1
  // Per-app sessionStorage layer for the incognito / third-party-cookie
2
- // fallback session token: the opaque `sid` from a `brass_code` exchange, so
3
- // a hard reload in a cookie-blocked browser (incognito, Safari ITP) keeps
4
- // the user signed in. In a normal browser the cross-site cookie carries the
5
- // session and nothing is written here.
2
+ // fallback session token: the opaque pointer a `brass_code` exchange hands
3
+ // back, so a hard reload in a cookie-blocked browser (incognito, Safari ITP)
4
+ // keeps the user signed in. In a normal browser the cross-site cookie carries
5
+ // the session and nothing is written here.
6
6
  //
7
- // The value is no more sensitive than the cookie it mirrors: an opaque
8
- // pointer, useless off an allowlisted Origin, gated by `approved_apps`
9
- // server-side, same 30-day TTL. `sessionStorage` (per-tab, dropped on tab
10
- // close) matches the cookie-channel UX. Keyed per-app id.
7
+ // The pointer is bound server-side to the app it was minted for, so its
8
+ // authority is this app and it dies with this app's per-app sign-out. That is
9
+ // what makes it safe to persist at all, and why the key is per-app id.
10
+ // `sessionStorage` (per-tab, dropped on tab close) matches the cookie-channel
11
+ // UX.
11
12
 
12
13
  const KEY_PREFIX = '__brass_session_';
13
14
 
@@ -16,7 +16,7 @@
16
16
  // never mint a grant themselves.
17
17
 
18
18
  import type { BrassUser } from './types.js';
19
- import type { Command } from './documents.js';
19
+ import type { Command, Document as BrassDocument } from './documents.js';
20
20
  import type { DocumentSession } from './session.js';
21
21
  import type { PresenceParticipant } from './presence.js';
22
22
  import type { DocumentDriveStatus } from './document-signals.js';
@@ -59,9 +59,6 @@ export interface VersionPreviewDeps {
59
59
  dashboardBaseUrl: string;
60
60
  // Pins the embed's light/dark theme (matches the other hosted surfaces).
61
61
  hostedUiTheme?: 'light' | 'dark' | 'auto';
62
- // Fallback platform-session pointer, forwarded to the embed for the
63
- // cookie-blocked (incognito / Safari ITP) case.
64
- getSessionToken?: () => string | null | undefined;
65
62
  }
66
63
 
67
64
  export interface AccountMenuDeps {
@@ -69,9 +66,9 @@ export interface AccountMenuDeps {
69
66
  onUserChange: (listener: (user: BrassUser | null) => void) => () => void;
70
67
  signIn: () => void;
71
68
  signOut: () => void;
72
- // Base URL of the Brass dashboard, for the account menu's "Open dashboard"
73
- // link. The client's resolved dashboard origin (picks the environment),
74
- // with no trailing slash.
69
+ // Base URL of the Brass dashboard, for the account menu's "Open Brass
70
+ // dashboard" link. The client's resolved dashboard origin (picks the
71
+ // environment), with no trailing slash.
75
72
  dashboardBaseUrl: string;
76
73
  }
77
74
 
@@ -254,7 +251,30 @@ const CSS = `
254
251
  fallback shown when no image is set or the image fails to load. */
255
252
  .b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
256
253
  .b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
257
- .b-email { color:var(--brass-fg-muted); font-size:12px; padding:2px 4px; word-break:break-all; }
254
+ /* The account menu opens on the identity it acts for: the avatar again at
255
+ reading size, the name, and the email under it. The rows below are the
256
+ actions on that identity, so they carry a glyph and sit apart from the
257
+ header behind a rule. */
258
+ .b-account .b-pop { min-width:236px; max-width:min(300px, calc(100vw - 16px)); }
259
+ .b-account-head { display:flex; align-items:center; gap:10px; padding:6px 8px 8px; }
260
+ .b-account-face { flex:0 0 auto; width:36px; height:36px; border-radius:50%;
261
+ display:inline-flex; align-items:center; justify-content:center; overflow:hidden;
262
+ background:var(--brass-accent); color:var(--brass-accent-fg);
263
+ font-size:15px; font-weight:600; }
264
+ /* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
265
+ menu: a grid item's default min-width is its content's intrinsic size. */
266
+ .b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
267
+ .b-account-name, .b-email { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
268
+ .b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
269
+ .b-email { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
270
+ .b-account .b-pop button, .b-account .b-pop a {
271
+ display:flex; align-items:center; gap:10px; font-size:13px; }
272
+ .b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
273
+ .b-account .b-pop button:hover svg, .b-account .b-pop a:hover svg { color:var(--brass-fg); }
274
+ /* Restated at this specificity so the flex rows above don't unhide a row the
275
+ guest / signed-in swap hides. */
276
+ .b-account .b-pop button[hidden], .b-account .b-pop a[hidden],
277
+ .b-account .b-pop .b-account-head[hidden], .b-account .b-pop .b-sep[hidden] { display:none; }
258
278
  /* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
259
279
  there is something to do. Clicking opens a top-layer Popover (never clipped
260
280
  by the header overflow, light-dismissed on outside tap). */
@@ -304,6 +324,23 @@ const CSS = `
304
324
  background:var(--brass-surface-2); color:var(--brass-fg-muted);
305
325
  border-bottom:1px solid var(--brass-border); }
306
326
  .b-version iframe { display:block; }
327
+ /* The SDK-drawn strip that stands in when the embed reports it cannot render.
328
+ Same box as the placeholder, plus the two controls that need no version
329
+ timeline, so returning to the live head stays reachable. */
330
+ .b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
331
+ min-height:44px; padding:0 14px; font-size:13px;
332
+ background:var(--brass-surface-2); color:var(--brass-fg-muted);
333
+ border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
334
+ .b-version-fallback[hidden] { display:none; }
335
+ /* Takes the strip's slack, so the controls hold the trailing edge whichever
336
+ of them is showing, and it is the only part that ellipsizes. */
337
+ .b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
338
+ text-overflow:ellipsis; white-space:nowrap; }
339
+ .b-version-fallback button { flex:0 0 auto; background:transparent;
340
+ border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
341
+ color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
342
+ .b-version-fallback button:hover { background:var(--brass-surface); }
343
+ .b-version-fallback button[hidden] { display:none; }
307
344
  /* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
308
345
  (negative outline-offset), because the left cluster and the title row clip
309
346
  their overflow so a long name ellipsizes: a ring outside the box falls in
@@ -349,6 +386,43 @@ function initialOf(user: BrassUser): string {
349
386
  return label.trim().charAt(0).toUpperCase() || '?';
350
387
  }
351
388
 
389
+ // A user's face: the uploaded image over a monogram. The monogram is the
390
+ // fallback, shown when the user has no avatar image and restored if the image
391
+ // fails to load (a broken / unreachable URL renders the initial, never a
392
+ // broken-image glyph). The caller places the pair inside its own circle, so
393
+ // the account menu's trigger and its header draw the same face at two sizes.
394
+ function buildAvatarFace(): {
395
+ nodes: [HTMLImageElement, HTMLSpanElement];
396
+ render(user: BrassUser | null): void;
397
+ } {
398
+ const img = el('img', 'b-avatar-img');
399
+ img.alt = '';
400
+ const initial = el('span', 'b-avatar-initial');
401
+ const showMonogram = (): void => {
402
+ img.hidden = true;
403
+ initial.hidden = false;
404
+ };
405
+ img.addEventListener('error', () => {
406
+ img.removeAttribute('src');
407
+ showMonogram();
408
+ });
409
+ return {
410
+ nodes: [img, initial],
411
+ render(user: BrassUser | null): void {
412
+ initial.textContent = user ? initialOf(user) : '?';
413
+ const url = user?.avatar_url?.trim() ?? '';
414
+ if (!url) {
415
+ img.removeAttribute('src');
416
+ showMonogram();
417
+ return;
418
+ }
419
+ if (img.getAttribute('src') !== url) img.src = url;
420
+ img.hidden = false;
421
+ initial.hidden = true;
422
+ },
423
+ };
424
+ }
425
+
352
426
  function renderCommandButton(cmd: Command, asTool: boolean): HTMLButtonElement {
353
427
  const b = el('button', asTool ? 'b-tool' : undefined);
354
428
  b.type = 'button';
@@ -584,7 +658,11 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
584
658
  loc.hidden = true;
585
659
  root.append(titleRow, loc);
586
660
 
587
- let lastDocId: string | null = null;
661
+ // The document the location chip describes, held by INSTANCE. A branch
662
+ // switch, and a re-open after the document moves, bind a new Document under
663
+ // the same id, and a move is exactly the event that changes the location
664
+ // label, so an id comparison would leave the chip naming the old project.
665
+ let boundDoc: DocumentSession['document'] = null;
588
666
  // The non-default branch label (null on the default), read by `fitBranch`
589
667
  // so a resize re-evaluates the drop without a full re-render.
590
668
  let branchLabel: string | null = null;
@@ -608,8 +686,9 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
608
686
  return deps.session.document?._currentBranchName() ?? null;
609
687
  }
610
688
 
611
- async function refreshLocation(): Promise<void> {
612
- const doc = deps.session.document;
689
+ // Resolves the label for one document, named by the caller so the in-flight
690
+ // fetch is attributed to the Document it was started for.
691
+ async function refreshLocation(doc: DocumentSession['document']): Promise<void> {
613
692
  const docId = doc?.id ?? null;
614
693
  if (!docId) {
615
694
  loc.hidden = true;
@@ -617,7 +696,7 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
617
696
  }
618
697
  try {
619
698
  const record = await deps.getDocument(docId);
620
- if (deps.session.document?.id !== docId) return; // superseded
699
+ if (deps.session.document !== doc) return; // superseded
621
700
  // The server hands back an opaque location label (rendered verbatim);
622
701
  // the toolbar never composes it from project / org ids, so the
623
702
  // hierarchy above the document can evolve without a client change. A
@@ -633,14 +712,14 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
633
712
  }
634
713
  loc.hidden = false;
635
714
  } catch {
636
- if (deps.session.document?.id === docId) loc.hidden = true;
715
+ if (deps.session.document === doc) loc.hidden = true;
637
716
  }
638
717
  }
639
718
 
640
719
  function runManage(): void {
641
720
  void (async () => {
642
721
  await deps.session.run('manage');
643
- void refreshLocation();
722
+ void refreshLocation(deps.session.document);
644
723
  })();
645
724
  }
646
725
  title.onclick = runManage;
@@ -675,11 +754,10 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
675
754
  branch.title = `On branch "${branchLabel}"`;
676
755
  }
677
756
  fitBranch();
678
- // Re-resolve location only when the active document changes.
679
- const docId = doc?.id ?? null;
680
- if (docId !== lastDocId) {
681
- lastDocId = docId;
682
- void refreshLocation();
757
+ // Re-resolve location only when the bound document changes.
758
+ if (doc !== boundDoc) {
759
+ boundDoc = doc;
760
+ void refreshLocation(doc);
683
761
  }
684
762
  };
685
763
  render();
@@ -728,7 +806,12 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
728
806
  injectStyles(document);
729
807
  const root = el('div', 'brass-ui b-roster');
730
808
 
731
- let lastDocId: string | null = null;
809
+ // The document whose presence client is subscribed, held by INSTANCE.
810
+ // Presence is scoped to a (document, branch) pair, and a branch switch binds
811
+ // a new Document under the SAME id, so an id comparison would keep the
812
+ // roster subscribed to the branch the user just left and show the wrong
813
+ // people as present on the one they are looking at.
814
+ let boundDoc: DocumentSession['document'] = null;
732
815
  let presenceOff: (() => void) | null = null;
733
816
 
734
817
  const renderRoster = (participants: readonly PresenceParticipant[]): void => {
@@ -773,9 +856,9 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
773
856
  }
774
857
 
775
858
  const render = (): void => {
776
- const docId = deps.session.document?.id ?? null;
777
- if (docId !== lastDocId) {
778
- lastDocId = docId;
859
+ const doc = deps.session.document;
860
+ if (doc !== boundDoc) {
861
+ boundDoc = doc;
779
862
  bindPresence();
780
863
  }
781
864
  };
@@ -790,10 +873,10 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
790
873
  };
791
874
  }
792
875
 
793
- // A monochrome chain-link glyph (inherits `color` via `currentColor`). Built
876
+ // A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
794
877
  // in the SVG namespace because `createElement('svg')` yields an HTML element
795
878
  // that won't render.
796
- function chainLinkIcon(): SVGSVGElement {
879
+ function strokeIcon(...paths: string[]): SVGSVGElement {
797
880
  const ns = 'http://www.w3.org/2000/svg';
798
881
  const svg = document.createElementNS(ns, 'svg');
799
882
  svg.setAttribute('width', '16');
@@ -805,10 +888,7 @@ function chainLinkIcon(): SVGSVGElement {
805
888
  svg.setAttribute('stroke-linecap', 'round');
806
889
  svg.setAttribute('stroke-linejoin', 'round');
807
890
  svg.setAttribute('aria-hidden', 'true');
808
- for (const d of [
809
- 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
810
- 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
811
- ]) {
891
+ for (const d of paths) {
812
892
  const path = document.createElementNS(ns, 'path');
813
893
  path.setAttribute('d', d);
814
894
  svg.append(path);
@@ -816,11 +896,34 @@ function chainLinkIcon(): SVGSVGElement {
816
896
  return svg;
817
897
  }
818
898
 
899
+ function chainLinkIcon(): SVGSVGElement {
900
+ return strokeIcon(
901
+ 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
902
+ 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
903
+ );
904
+ }
905
+
819
906
  // How long the popover holds its "Publishing..." affordance after a push
820
907
  // before giving up on the synced confirmation. Long enough to cover a normal
821
908
  // drive webhook round-trip.
822
909
  const PUBLISH_HOLD_MS = 30000;
823
910
 
911
+ // `value` when it is an absolute http(s) URL, else null. The drive-sync
912
+ // popover's "View in <drive>" link is the one place the toolbar puts a
913
+ // server-supplied string into an `href`, and an href runs whatever scheme it
914
+ // names: a `javascript:` URL executes in the app's own page on the click.
915
+ // A relative URL throws out of `new URL` and is refused with the rest, which
916
+ // is right here, since the link points at an external drive item.
917
+ function httpUrlOrNull(value: string): string | null {
918
+ let url: URL;
919
+ try {
920
+ url = new URL(value);
921
+ } catch {
922
+ return null;
923
+ }
924
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
925
+ }
926
+
824
927
  // The drive-sync indicator: a link icon (dim at rest, accent / danger with a
825
928
  // dot when there is something to do) whose popover describes the state
826
929
  // (server-authored copy, verbatim) and offers publish / resolve. The receiver
@@ -849,7 +952,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
849
952
  dot.setAttribute('aria-hidden', 'true');
850
953
  button.append(dot);
851
954
 
852
- let lastDocId: string | null = null;
955
+ // The document whose drive status is subscribed, held by INSTANCE. A branch
956
+ // switch binds a new Document under the same id, and each carries its own
957
+ // drive state, so an id comparison would leave the indicator describing the
958
+ // document that was closed.
959
+ let boundDoc: DocumentSession['document'] = null;
853
960
  let statusOff: (() => void) | null = null;
854
961
 
855
962
  // A publish runs on a server worker and reaches `synced` only after the
@@ -916,10 +1023,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
916
1023
  // there is one), then the commands. Rebuilt on every status change, so an
917
1024
  // open popover updates live.
918
1025
  const rows: HTMLElement[] = [];
919
- if (status.itemUrl) {
1026
+ const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
1027
+ if (itemUrl) {
920
1028
  const head = el('div', 'b-drive-head');
921
1029
  const link = el('a', 'b-drive-source');
922
- link.href = status.itemUrl;
1030
+ link.href = itemUrl;
923
1031
  link.target = '_blank';
924
1032
  link.rel = 'noopener noreferrer';
925
1033
  link.textContent = `View in ${driveName}`;
@@ -1017,9 +1125,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1017
1125
  }
1018
1126
 
1019
1127
  const onSession = (): void => {
1020
- const docId = deps.session.document?.id ?? null;
1021
- if (docId !== lastDocId) {
1022
- lastDocId = docId;
1128
+ const doc = deps.session.document;
1129
+ if (doc !== boundDoc) {
1130
+ boundDoc = doc;
1023
1131
  bind();
1024
1132
  }
1025
1133
  };
@@ -1044,18 +1152,41 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1044
1152
  label: 'Account',
1045
1153
  align: 'end',
1046
1154
  });
1155
+ // The menu opens on the identity it acts for: the avatar at reading size,
1156
+ // the display name, and the email under it. The name line drops out when the
1157
+ // user has none, leaving the email as the single identifying line.
1158
+ const head = el('div', 'b-account-head');
1159
+ const headFace = buildAvatarFace();
1160
+ const faceEl = el('span', 'b-account-face');
1161
+ faceEl.append(...headFace.nodes);
1162
+ const nameEl = el('div', 'b-account-name');
1047
1163
  const emailEl = el('div', 'b-email');
1164
+ const identity = el('div', 'b-account-id');
1165
+ identity.append(nameEl, emailEl);
1166
+ head.append(faceEl, identity);
1167
+ const headSep = el('hr', 'b-sep');
1168
+ headSep.setAttribute('role', 'separator');
1048
1169
  // Opens the dashboard in a new tab, so the user keeps the app open in the
1049
1170
  // current one. `/home` is the authenticated landing (resolves the active
1050
1171
  // org); the bare origin is the sign-in page.
1051
1172
  const dashboardLink = el('a');
1052
- dashboardLink.textContent = 'Open dashboard';
1173
+ dashboardLink.append(
1174
+ strokeIcon(
1175
+ 'M14 3h7v7',
1176
+ 'M10 14 21 3',
1177
+ 'M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5',
1178
+ ),
1179
+ 'Open Brass dashboard',
1180
+ );
1053
1181
  dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
1054
1182
  dashboardLink.target = '_blank';
1055
1183
  dashboardLink.rel = 'noopener noreferrer';
1056
1184
  dashboardLink.onclick = () => menu.close();
1057
1185
  const signOutBtn = el('button');
1058
- signOutBtn.textContent = 'Sign out';
1186
+ signOutBtn.append(
1187
+ strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'),
1188
+ 'Sign out',
1189
+ );
1059
1190
  signOutBtn.onclick = () => {
1060
1191
  menu.close();
1061
1192
  deps.signOut();
@@ -1065,50 +1196,37 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1065
1196
  // meaningless, so the menu offers a single "Sign in" that takes the top
1066
1197
  // window through the app's normal sign-in and returns to this URL.
1067
1198
  const signInBtn = el('button');
1068
- signInBtn.textContent = 'Sign in';
1199
+ signInBtn.append(
1200
+ strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'),
1201
+ 'Sign in',
1202
+ );
1069
1203
  signInBtn.onclick = () => {
1070
1204
  menu.close();
1071
1205
  deps.signIn();
1072
1206
  };
1073
- menu.pop.append(emailEl, dashboardLink, signOutBtn, signInBtn);
1207
+ menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
1074
1208
 
1075
- // The avatar trigger holds both the uploaded image and the monogram. The
1076
- // monogram is the fallback: shown when the user has no avatar image, and
1077
- // restored if the image fails to load (a broken / unreachable URL renders
1078
- // the initial, never a broken-image glyph).
1079
- const avatarImg = el('img', 'b-avatar-img');
1080
- avatarImg.alt = '';
1081
- const initialEl = el('span', 'b-avatar-initial');
1082
- menu.trigger.append(avatarImg, initialEl);
1083
-
1084
- const showMonogram = (): void => {
1085
- avatarImg.hidden = true;
1086
- initialEl.hidden = false;
1087
- };
1088
- avatarImg.addEventListener('error', () => {
1089
- avatarImg.removeAttribute('src');
1090
- showMonogram();
1091
- });
1209
+ const triggerFace = buildAvatarFace();
1210
+ menu.trigger.append(...triggerFace.nodes);
1092
1211
 
1093
1212
  const renderUser = (user: BrassUser | null): void => {
1094
- initialEl.textContent = user ? initialOf(user) : '?';
1095
- emailEl.textContent = user ? (user.email ?? user.display_name ?? user.sub) : '';
1213
+ triggerFace.render(user);
1214
+ headFace.render(user);
1215
+ nameEl.textContent = user?.display_name ?? '';
1216
+ nameEl.hidden = !user?.display_name;
1217
+ emailEl.textContent = user ? (user.email ?? user.sub) : '';
1218
+ // The email ellipsizes to hold the menu's width, so the untruncated
1219
+ // address stays reachable on hover.
1220
+ emailEl.title = emailEl.textContent;
1096
1221
  // Swap the menu body between the signed-in actions and the guest's lone
1097
1222
  // "Sign in": a null user is an anonymous link holder.
1098
1223
  const signedIn = user !== null;
1224
+ head.hidden = !signedIn;
1225
+ headSep.hidden = !signedIn;
1099
1226
  emailEl.hidden = !signedIn;
1100
1227
  dashboardLink.hidden = !signedIn;
1101
1228
  signOutBtn.hidden = !signedIn;
1102
1229
  signInBtn.hidden = signedIn;
1103
- const url = user?.avatar_url?.trim() ?? '';
1104
- if (url) {
1105
- if (avatarImg.getAttribute('src') !== url) avatarImg.src = url;
1106
- avatarImg.hidden = false;
1107
- initialEl.hidden = true;
1108
- } else {
1109
- avatarImg.removeAttribute('src');
1110
- showMonogram();
1111
- }
1112
1230
  };
1113
1231
  renderUser(deps.getUser());
1114
1232
  const unsub = deps.onUserChange(renderUser);
@@ -1144,6 +1262,14 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1144
1262
  // closes it, when the active document changes, or when no doc is open.
1145
1263
  let browsing = false;
1146
1264
  let lastDocId: string | null = null;
1265
+ // The version the reader dismissed the bar at, when the document was left
1266
+ // holding one. A document that cannot return to its head (one pinned to a
1267
+ // version) stays on it, so without this the next render reads a non-null
1268
+ // `doc.version` and latches the bar the reader just dismissed straight back
1269
+ // on. A dismissal at the live head records nothing: there is no version
1270
+ // left to latch on, and the head is where the next browse-in-the-app
1271
+ // request arrives, so a record there would swallow it.
1272
+ let dismissedAt: number | undefined = undefined;
1147
1273
 
1148
1274
  const session = deps.session;
1149
1275
  const hideBar = (): void => {
@@ -1153,27 +1279,44 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1153
1279
  frame = null;
1154
1280
  }
1155
1281
  };
1282
+ // A failed intent reaches the reader through the bar (which reverts to the
1283
+ // document's real state) and the developer through `Brass.onError`. Both
1284
+ // halves matter: the bar moves optimistically, so a swallowed failure is a
1285
+ // bar naming a version nothing is showing.
1286
+ const attempt = async (
1287
+ doc: BrassDocument,
1288
+ act: () => void | Promise<void>,
1289
+ ): Promise<void> => {
1290
+ try {
1291
+ await act();
1292
+ } catch (err) {
1293
+ doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
1294
+ throw err;
1295
+ }
1296
+ };
1156
1297
  const frameDeps: VersionBarDeps = {
1157
1298
  dashboardBaseUrl: deps.dashboardBaseUrl,
1158
1299
  ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
1159
- ...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
1160
1300
  // Read the live active document at call time: a bridge intent lands
1161
1301
  // async, so it must not close over a doc that may have been superseded.
1162
- checkoutVersion: (version) => {
1163
- void session.document?._checkoutVersion(version).catch(() => {});
1302
+ currentVersion: () => session.document?.version ?? null,
1303
+ checkoutVersion: async (version) => {
1304
+ const doc = session.document;
1305
+ if (!doc) return;
1306
+ await attempt(doc, () => doc._checkoutVersion(version));
1164
1307
  },
1165
- returnToHead: () => {
1308
+ returnToHead: async () => {
1166
1309
  // Return to the live head but keep the bar open (the browsing session
1167
1310
  // persists); the bar re-renders in its "at latest" state with a Close
1168
1311
  // button. Dismissal is the explicit `close` intent only.
1169
- try {
1170
- session.document?.returnToHead();
1171
- } catch {
1172
- // frozen / no live head: leave the preview as-is
1173
- }
1312
+ const doc = session.document;
1313
+ if (!doc) return;
1314
+ await attempt(doc, () => doc.returnToHead());
1174
1315
  },
1175
1316
  fork: (fromVersion, name) => {
1176
- void session.document?._forkFromVersion(fromVersion, name).catch(() => {});
1317
+ const doc = session.document;
1318
+ if (!doc) return;
1319
+ void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => {});
1177
1320
  },
1178
1321
  browse: () => {
1179
1322
  void session.run('history');
@@ -1182,8 +1325,18 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1182
1325
  // Explicit dismissal: end the browsing session and hide the bar
1183
1326
  // synchronously so the tap is instant. The document's own browsing
1184
1327
  // flag is cleared too, or the next render would latch the bar back on.
1328
+ const doc = session.document;
1185
1329
  browsing = false;
1186
- session.document?._setVersionBrowsing(false);
1330
+ dismissedAt = doc?.version ?? undefined;
1331
+ doc?._setVersionBrowsing(false);
1332
+ // Leaving the bar means leaving the preview: the bar is the only
1333
+ // surface that returns a document to its head, so a dismissal that
1334
+ // left it checked out would leave an app read-only with nothing to
1335
+ // clear it. A document pinned to its version has no head to return to
1336
+ // and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
1337
+ if (doc && doc.version !== null) {
1338
+ void attempt(doc, () => doc.returnToHead()).catch(() => {});
1339
+ }
1187
1340
  hideBar();
1188
1341
  },
1189
1342
  };
@@ -1191,21 +1344,36 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1191
1344
  const render = (): void => {
1192
1345
  const doc = session.document;
1193
1346
  const docId = doc?.id ?? null;
1194
- // A new active document (or none) ends any prior browsing session.
1347
+ // A new active document (or none) ends any prior browsing session. Keyed
1348
+ // on the id rather than the Document instance because a checkout keeps the
1349
+ // instance and only moves its `version`, so the bar follows the document
1350
+ // the user is stepping through rather than closing under them.
1195
1351
  if (docId !== lastDocId) {
1196
1352
  lastDocId = docId;
1197
1353
  browsing = false;
1354
+ dismissedAt = undefined;
1198
1355
  }
1199
1356
  // A live checkout (`doc.version !== null`) starts / keeps the browsing
1200
1357
  // session. Other read-only states (a view-only share link, a still-loading
1201
1358
  // doc) are not this bar's concern, and `doc.id` is null only on the offline
1202
1359
  // / test path the embed can't serve.
1360
+ // `versionBrowsing` is set by the reader asking for this surface, and the
1361
+ // close cleared it, so it names a fresh request whatever version the
1362
+ // document is on and outranks the dismissal.
1203
1363
  if (
1204
1364
  doc != null &&
1205
1365
  docId !== null &&
1206
- (doc.version !== null || doc.versionBrowsing)
1366
+ (doc.version !== null || doc.versionBrowsing) &&
1367
+ (doc.versionBrowsing || doc.version !== dismissedAt)
1207
1368
  ) {
1208
1369
  browsing = true;
1370
+ dismissedAt = undefined;
1371
+ // Mirror the open bar onto the document, which is what makes it
1372
+ // read-only for as long as the bar is up. A preview entered by picking
1373
+ // a version sets only `version`, so without this the reader who then
1374
+ // returns to latest keeps the bar and gets the document back, which is
1375
+ // the pair the flag exists to keep together.
1376
+ doc._setVersionBrowsing(true);
1209
1377
  }
1210
1378
  // The bar stays open for the whole session, including at the live head
1211
1379
  // (`doc.version === null`, browsing still true), until `close`.
@@ -1217,6 +1385,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1217
1385
  branch: doc.branchId ?? undefined,
1218
1386
  version: doc.version,
1219
1387
  branchName: doc._currentBranchName() ?? undefined,
1388
+ pinned: doc._isVersionPinned,
1220
1389
  });
1221
1390
  } else {
1222
1391
  hideBar();