@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
@@ -144,7 +144,30 @@ const CSS = `
144
144
  fallback shown when no image is set or the image fails to load. */
145
145
  .b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
146
146
  .b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
147
- .b-email { color:var(--brass-fg-muted); font-size:12px; padding:2px 4px; word-break:break-all; }
147
+ /* The account menu opens on the identity it acts for: the avatar again at
148
+ reading size, the name, and the email under it. The rows below are the
149
+ actions on that identity, so they carry a glyph and sit apart from the
150
+ header behind a rule. */
151
+ .b-account .b-pop { min-width:236px; max-width:min(300px, calc(100vw - 16px)); }
152
+ .b-account-head { display:flex; align-items:center; gap:10px; padding:6px 8px 8px; }
153
+ .b-account-face { flex:0 0 auto; width:36px; height:36px; border-radius:50%;
154
+ display:inline-flex; align-items:center; justify-content:center; overflow:hidden;
155
+ background:var(--brass-accent); color:var(--brass-accent-fg);
156
+ font-size:15px; font-weight:600; }
157
+ /* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
158
+ menu: a grid item's default min-width is its content's intrinsic size. */
159
+ .b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
160
+ .b-account-name, .b-email { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
161
+ .b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
162
+ .b-email { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
163
+ .b-account .b-pop button, .b-account .b-pop a {
164
+ display:flex; align-items:center; gap:10px; font-size:13px; }
165
+ .b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
166
+ .b-account .b-pop button:hover svg, .b-account .b-pop a:hover svg { color:var(--brass-fg); }
167
+ /* Restated at this specificity so the flex rows above don't unhide a row the
168
+ guest / signed-in swap hides. */
169
+ .b-account .b-pop button[hidden], .b-account .b-pop a[hidden],
170
+ .b-account .b-pop .b-account-head[hidden], .b-account .b-pop .b-sep[hidden] { display:none; }
148
171
  /* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
149
172
  there is something to do. Clicking opens a top-layer Popover (never clipped
150
173
  by the header overflow, light-dismissed on outside tap). */
@@ -194,6 +217,23 @@ const CSS = `
194
217
  background:var(--brass-surface-2); color:var(--brass-fg-muted);
195
218
  border-bottom:1px solid var(--brass-border); }
196
219
  .b-version iframe { display:block; }
220
+ /* The SDK-drawn strip that stands in when the embed reports it cannot render.
221
+ Same box as the placeholder, plus the two controls that need no version
222
+ timeline, so returning to the live head stays reachable. */
223
+ .b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
224
+ min-height:44px; padding:0 14px; font-size:13px;
225
+ background:var(--brass-surface-2); color:var(--brass-fg-muted);
226
+ border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
227
+ .b-version-fallback[hidden] { display:none; }
228
+ /* Takes the strip's slack, so the controls hold the trailing edge whichever
229
+ of them is showing, and it is the only part that ellipsizes. */
230
+ .b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
231
+ text-overflow:ellipsis; white-space:nowrap; }
232
+ .b-version-fallback button { flex:0 0 auto; background:transparent;
233
+ border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
234
+ color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
235
+ .b-version-fallback button:hover { background:var(--brass-surface); }
236
+ .b-version-fallback button[hidden] { display:none; }
197
237
  /* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
198
238
  (negative outline-offset), because the left cluster and the title row clip
199
239
  their overflow so a long name ellipsizes: a ring outside the box falls in
@@ -234,6 +274,40 @@ function initialOf(user) {
234
274
  const label = user.display_name ?? user.email ?? user.sub;
235
275
  return label.trim().charAt(0).toUpperCase() || '?';
236
276
  }
277
+ // A user's face: the uploaded image over a monogram. The monogram is the
278
+ // fallback, shown when the user has no avatar image and restored if the image
279
+ // fails to load (a broken / unreachable URL renders the initial, never a
280
+ // broken-image glyph). The caller places the pair inside its own circle, so
281
+ // the account menu's trigger and its header draw the same face at two sizes.
282
+ function buildAvatarFace() {
283
+ const img = el('img', 'b-avatar-img');
284
+ img.alt = '';
285
+ const initial = el('span', 'b-avatar-initial');
286
+ const showMonogram = () => {
287
+ img.hidden = true;
288
+ initial.hidden = false;
289
+ };
290
+ img.addEventListener('error', () => {
291
+ img.removeAttribute('src');
292
+ showMonogram();
293
+ });
294
+ return {
295
+ nodes: [img, initial],
296
+ render(user) {
297
+ initial.textContent = user ? initialOf(user) : '?';
298
+ const url = user?.avatar_url?.trim() ?? '';
299
+ if (!url) {
300
+ img.removeAttribute('src');
301
+ showMonogram();
302
+ return;
303
+ }
304
+ if (img.getAttribute('src') !== url)
305
+ img.src = url;
306
+ img.hidden = false;
307
+ initial.hidden = true;
308
+ },
309
+ };
310
+ }
237
311
  function renderCommandButton(cmd, asTool) {
238
312
  const b = el('button', asTool ? 'b-tool' : undefined);
239
313
  b.type = 'button';
@@ -425,7 +499,11 @@ function buildDocumentTitle(deps) {
425
499
  loc.type = 'button';
426
500
  loc.hidden = true;
427
501
  root.append(titleRow, loc);
428
- let lastDocId = null;
502
+ // The document the location chip describes, held by INSTANCE. A branch
503
+ // switch, and a re-open after the document moves, bind a new Document under
504
+ // the same id, and a move is exactly the event that changes the location
505
+ // label, so an id comparison would leave the chip naming the old project.
506
+ let boundDoc = null;
429
507
  // The non-default branch label (null on the default), read by `fitBranch`
430
508
  // so a resize re-evaluates the drop without a full re-render.
431
509
  let branchLabel = null;
@@ -447,8 +525,9 @@ function buildDocumentTitle(deps) {
447
525
  function activeBranchLabel() {
448
526
  return deps.session.document?._currentBranchName() ?? null;
449
527
  }
450
- async function refreshLocation() {
451
- const doc = deps.session.document;
528
+ // Resolves the label for one document, named by the caller so the in-flight
529
+ // fetch is attributed to the Document it was started for.
530
+ async function refreshLocation(doc) {
452
531
  const docId = doc?.id ?? null;
453
532
  if (!docId) {
454
533
  loc.hidden = true;
@@ -456,7 +535,7 @@ function buildDocumentTitle(deps) {
456
535
  }
457
536
  try {
458
537
  const record = await deps.getDocument(docId);
459
- if (deps.session.document?.id !== docId)
538
+ if (deps.session.document !== doc)
460
539
  return; // superseded
461
540
  // The server hands back an opaque location label (rendered verbatim);
462
541
  // the toolbar never composes it from project / org ids, so the
@@ -475,14 +554,14 @@ function buildDocumentTitle(deps) {
475
554
  loc.hidden = false;
476
555
  }
477
556
  catch {
478
- if (deps.session.document?.id === docId)
557
+ if (deps.session.document === doc)
479
558
  loc.hidden = true;
480
559
  }
481
560
  }
482
561
  function runManage() {
483
562
  void (async () => {
484
563
  await deps.session.run('manage');
485
- void refreshLocation();
564
+ void refreshLocation(deps.session.document);
486
565
  })();
487
566
  }
488
567
  title.onclick = runManage;
@@ -518,11 +597,10 @@ function buildDocumentTitle(deps) {
518
597
  branch.title = `On branch "${branchLabel}"`;
519
598
  }
520
599
  fitBranch();
521
- // Re-resolve location only when the active document changes.
522
- const docId = doc?.id ?? null;
523
- if (docId !== lastDocId) {
524
- lastDocId = docId;
525
- void refreshLocation();
600
+ // Re-resolve location only when the bound document changes.
601
+ if (doc !== boundDoc) {
602
+ boundDoc = doc;
603
+ void refreshLocation(doc);
526
604
  }
527
605
  };
528
606
  render();
@@ -565,7 +643,12 @@ function buildUndoRedo(deps) {
565
643
  function buildPresenceRoster(deps) {
566
644
  injectStyles(document);
567
645
  const root = el('div', 'brass-ui b-roster');
568
- let lastDocId = null;
646
+ // The document whose presence client is subscribed, held by INSTANCE.
647
+ // Presence is scoped to a (document, branch) pair, and a branch switch binds
648
+ // a new Document under the SAME id, so an id comparison would keep the
649
+ // roster subscribed to the branch the user just left and show the wrong
650
+ // people as present on the one they are looking at.
651
+ let boundDoc = null;
569
652
  let presenceOff = null;
570
653
  const renderRoster = (participants) => {
571
654
  root.replaceChildren(...participants.map((p) => {
@@ -607,9 +690,9 @@ function buildPresenceRoster(deps) {
607
690
  typeof off === 'function' ? off : () => presence.off('change', renderRoster);
608
691
  }
609
692
  const render = () => {
610
- const docId = deps.session.document?.id ?? null;
611
- if (docId !== lastDocId) {
612
- lastDocId = docId;
693
+ const doc = deps.session.document;
694
+ if (doc !== boundDoc) {
695
+ boundDoc = doc;
613
696
  bindPresence();
614
697
  }
615
698
  };
@@ -623,10 +706,10 @@ function buildPresenceRoster(deps) {
623
706
  },
624
707
  };
625
708
  }
626
- // A monochrome chain-link glyph (inherits `color` via `currentColor`). Built
709
+ // A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
627
710
  // in the SVG namespace because `createElement('svg')` yields an HTML element
628
711
  // that won't render.
629
- function chainLinkIcon() {
712
+ function strokeIcon(...paths) {
630
713
  const ns = 'http://www.w3.org/2000/svg';
631
714
  const svg = document.createElementNS(ns, 'svg');
632
715
  svg.setAttribute('width', '16');
@@ -638,20 +721,36 @@ function chainLinkIcon() {
638
721
  svg.setAttribute('stroke-linecap', 'round');
639
722
  svg.setAttribute('stroke-linejoin', 'round');
640
723
  svg.setAttribute('aria-hidden', 'true');
641
- for (const d of [
642
- 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
643
- 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
644
- ]) {
724
+ for (const d of paths) {
645
725
  const path = document.createElementNS(ns, 'path');
646
726
  path.setAttribute('d', d);
647
727
  svg.append(path);
648
728
  }
649
729
  return svg;
650
730
  }
731
+ function chainLinkIcon() {
732
+ return strokeIcon('M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71', 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71');
733
+ }
651
734
  // How long the popover holds its "Publishing..." affordance after a push
652
735
  // before giving up on the synced confirmation. Long enough to cover a normal
653
736
  // drive webhook round-trip.
654
737
  const PUBLISH_HOLD_MS = 30000;
738
+ // `value` when it is an absolute http(s) URL, else null. The drive-sync
739
+ // popover's "View in <drive>" link is the one place the toolbar puts a
740
+ // server-supplied string into an `href`, and an href runs whatever scheme it
741
+ // names: a `javascript:` URL executes in the app's own page on the click.
742
+ // A relative URL throws out of `new URL` and is refused with the rest, which
743
+ // is right here, since the link points at an external drive item.
744
+ function httpUrlOrNull(value) {
745
+ let url;
746
+ try {
747
+ url = new URL(value);
748
+ }
749
+ catch {
750
+ return null;
751
+ }
752
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
753
+ }
655
754
  // The drive-sync indicator: a link icon (dim at rest, accent / danger with a
656
755
  // dot when there is something to do) whose popover describes the state
657
756
  // (server-authored copy, verbatim) and offers publish / resolve. The receiver
@@ -679,7 +778,11 @@ function buildDriveSync(deps) {
679
778
  const dot = el('span', 'b-drive-dot');
680
779
  dot.setAttribute('aria-hidden', 'true');
681
780
  button.append(dot);
682
- let lastDocId = null;
781
+ // The document whose drive status is subscribed, held by INSTANCE. A branch
782
+ // switch binds a new Document under the same id, and each carries its own
783
+ // drive state, so an id comparison would leave the indicator describing the
784
+ // document that was closed.
785
+ let boundDoc = null;
683
786
  let statusOff = null;
684
787
  // A publish runs on a server worker and reaches `synced` only after the
685
788
  // worker (and, on a file-roundtrip, the drive's importer) confirms. In that
@@ -742,10 +845,11 @@ function buildDriveSync(deps) {
742
845
  // there is one), then the commands. Rebuilt on every status change, so an
743
846
  // open popover updates live.
744
847
  const rows = [];
745
- if (status.itemUrl) {
848
+ const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
849
+ if (itemUrl) {
746
850
  const head = el('div', 'b-drive-head');
747
851
  const link = el('a', 'b-drive-source');
748
- link.href = status.itemUrl;
852
+ link.href = itemUrl;
749
853
  link.target = '_blank';
750
854
  link.rel = 'noopener noreferrer';
751
855
  link.textContent = `View in ${driveName}`;
@@ -839,9 +943,9 @@ function buildDriveSync(deps) {
839
943
  statusOff = doc.onDriveStatus(render);
840
944
  }
841
945
  const onSession = () => {
842
- const docId = deps.session.document?.id ?? null;
843
- if (docId !== lastDocId) {
844
- lastDocId = docId;
946
+ const doc = deps.session.document;
947
+ if (doc !== boundDoc) {
948
+ boundDoc = doc;
845
949
  bind();
846
950
  }
847
951
  };
@@ -865,18 +969,31 @@ function buildAccountMenu(deps) {
865
969
  label: 'Account',
866
970
  align: 'end',
867
971
  });
972
+ // The menu opens on the identity it acts for: the avatar at reading size,
973
+ // the display name, and the email under it. The name line drops out when the
974
+ // user has none, leaving the email as the single identifying line.
975
+ const head = el('div', 'b-account-head');
976
+ const headFace = buildAvatarFace();
977
+ const faceEl = el('span', 'b-account-face');
978
+ faceEl.append(...headFace.nodes);
979
+ const nameEl = el('div', 'b-account-name');
868
980
  const emailEl = el('div', 'b-email');
981
+ const identity = el('div', 'b-account-id');
982
+ identity.append(nameEl, emailEl);
983
+ head.append(faceEl, identity);
984
+ const headSep = el('hr', 'b-sep');
985
+ headSep.setAttribute('role', 'separator');
869
986
  // Opens the dashboard in a new tab, so the user keeps the app open in the
870
987
  // current one. `/home` is the authenticated landing (resolves the active
871
988
  // org); the bare origin is the sign-in page.
872
989
  const dashboardLink = el('a');
873
- dashboardLink.textContent = 'Open dashboard';
990
+ dashboardLink.append(strokeIcon('M14 3h7v7', 'M10 14 21 3', 'M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5'), 'Open Brass dashboard');
874
991
  dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
875
992
  dashboardLink.target = '_blank';
876
993
  dashboardLink.rel = 'noopener noreferrer';
877
994
  dashboardLink.onclick = () => menu.close();
878
995
  const signOutBtn = el('button');
879
- signOutBtn.textContent = 'Sign out';
996
+ signOutBtn.append(strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'), 'Sign out');
880
997
  signOutBtn.onclick = () => {
881
998
  menu.close();
882
999
  deps.signOut();
@@ -886,49 +1003,32 @@ function buildAccountMenu(deps) {
886
1003
  // meaningless, so the menu offers a single "Sign in" that takes the top
887
1004
  // window through the app's normal sign-in and returns to this URL.
888
1005
  const signInBtn = el('button');
889
- signInBtn.textContent = 'Sign in';
1006
+ signInBtn.append(strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'), 'Sign in');
890
1007
  signInBtn.onclick = () => {
891
1008
  menu.close();
892
1009
  deps.signIn();
893
1010
  };
894
- menu.pop.append(emailEl, dashboardLink, signOutBtn, signInBtn);
895
- // The avatar trigger holds both the uploaded image and the monogram. The
896
- // monogram is the fallback: shown when the user has no avatar image, and
897
- // restored if the image fails to load (a broken / unreachable URL renders
898
- // the initial, never a broken-image glyph).
899
- const avatarImg = el('img', 'b-avatar-img');
900
- avatarImg.alt = '';
901
- const initialEl = el('span', 'b-avatar-initial');
902
- menu.trigger.append(avatarImg, initialEl);
903
- const showMonogram = () => {
904
- avatarImg.hidden = true;
905
- initialEl.hidden = false;
906
- };
907
- avatarImg.addEventListener('error', () => {
908
- avatarImg.removeAttribute('src');
909
- showMonogram();
910
- });
1011
+ menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
1012
+ const triggerFace = buildAvatarFace();
1013
+ menu.trigger.append(...triggerFace.nodes);
911
1014
  const renderUser = (user) => {
912
- initialEl.textContent = user ? initialOf(user) : '?';
913
- emailEl.textContent = user ? (user.email ?? user.display_name ?? user.sub) : '';
1015
+ triggerFace.render(user);
1016
+ headFace.render(user);
1017
+ nameEl.textContent = user?.display_name ?? '';
1018
+ nameEl.hidden = !user?.display_name;
1019
+ emailEl.textContent = user ? (user.email ?? user.sub) : '';
1020
+ // The email ellipsizes to hold the menu's width, so the untruncated
1021
+ // address stays reachable on hover.
1022
+ emailEl.title = emailEl.textContent;
914
1023
  // Swap the menu body between the signed-in actions and the guest's lone
915
1024
  // "Sign in": a null user is an anonymous link holder.
916
1025
  const signedIn = user !== null;
1026
+ head.hidden = !signedIn;
1027
+ headSep.hidden = !signedIn;
917
1028
  emailEl.hidden = !signedIn;
918
1029
  dashboardLink.hidden = !signedIn;
919
1030
  signOutBtn.hidden = !signedIn;
920
1031
  signInBtn.hidden = signedIn;
921
- const url = user?.avatar_url?.trim() ?? '';
922
- if (url) {
923
- if (avatarImg.getAttribute('src') !== url)
924
- avatarImg.src = url;
925
- avatarImg.hidden = false;
926
- initialEl.hidden = true;
927
- }
928
- else {
929
- avatarImg.removeAttribute('src');
930
- showMonogram();
931
- }
932
1032
  };
933
1033
  renderUser(deps.getUser());
934
1034
  const unsub = deps.onUserChange(renderUser);
@@ -962,6 +1062,14 @@ function buildVersionPreview(deps) {
962
1062
  // closes it, when the active document changes, or when no doc is open.
963
1063
  let browsing = false;
964
1064
  let lastDocId = null;
1065
+ // The version the reader dismissed the bar at, when the document was left
1066
+ // holding one. A document that cannot return to its head (one pinned to a
1067
+ // version) stays on it, so without this the next render reads a non-null
1068
+ // `doc.version` and latches the bar the reader just dismissed straight back
1069
+ // on. A dismissal at the live head records nothing: there is no version
1070
+ // left to latch on, and the head is where the next browse-in-the-app
1071
+ // request arrives, so a record there would swallow it.
1072
+ let dismissedAt = undefined;
965
1073
  const session = deps.session;
966
1074
  const hideBar = () => {
967
1075
  root.classList.remove('b-version-on');
@@ -970,28 +1078,45 @@ function buildVersionPreview(deps) {
970
1078
  frame = null;
971
1079
  }
972
1080
  };
1081
+ // A failed intent reaches the reader through the bar (which reverts to the
1082
+ // document's real state) and the developer through `Brass.onError`. Both
1083
+ // halves matter: the bar moves optimistically, so a swallowed failure is a
1084
+ // bar naming a version nothing is showing.
1085
+ const attempt = async (doc, act) => {
1086
+ try {
1087
+ await act();
1088
+ }
1089
+ catch (err) {
1090
+ doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
1091
+ throw err;
1092
+ }
1093
+ };
973
1094
  const frameDeps = {
974
1095
  dashboardBaseUrl: deps.dashboardBaseUrl,
975
1096
  ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
976
- ...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
977
1097
  // Read the live active document at call time: a bridge intent lands
978
1098
  // async, so it must not close over a doc that may have been superseded.
979
- checkoutVersion: (version) => {
980
- void session.document?._checkoutVersion(version).catch(() => { });
1099
+ currentVersion: () => session.document?.version ?? null,
1100
+ checkoutVersion: async (version) => {
1101
+ const doc = session.document;
1102
+ if (!doc)
1103
+ return;
1104
+ await attempt(doc, () => doc._checkoutVersion(version));
981
1105
  },
982
- returnToHead: () => {
1106
+ returnToHead: async () => {
983
1107
  // Return to the live head but keep the bar open (the browsing session
984
1108
  // persists); the bar re-renders in its "at latest" state with a Close
985
1109
  // button. Dismissal is the explicit `close` intent only.
986
- try {
987
- session.document?.returnToHead();
988
- }
989
- catch {
990
- // frozen / no live head: leave the preview as-is
991
- }
1110
+ const doc = session.document;
1111
+ if (!doc)
1112
+ return;
1113
+ await attempt(doc, () => doc.returnToHead());
992
1114
  },
993
1115
  fork: (fromVersion, name) => {
994
- void session.document?._forkFromVersion(fromVersion, name).catch(() => { });
1116
+ const doc = session.document;
1117
+ if (!doc)
1118
+ return;
1119
+ void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => { });
995
1120
  },
996
1121
  browse: () => {
997
1122
  void session.run('history');
@@ -1000,27 +1125,52 @@ function buildVersionPreview(deps) {
1000
1125
  // Explicit dismissal: end the browsing session and hide the bar
1001
1126
  // synchronously so the tap is instant. The document's own browsing
1002
1127
  // flag is cleared too, or the next render would latch the bar back on.
1128
+ const doc = session.document;
1003
1129
  browsing = false;
1004
- session.document?._setVersionBrowsing(false);
1130
+ dismissedAt = doc?.version ?? undefined;
1131
+ doc?._setVersionBrowsing(false);
1132
+ // Leaving the bar means leaving the preview: the bar is the only
1133
+ // surface that returns a document to its head, so a dismissal that
1134
+ // left it checked out would leave an app read-only with nothing to
1135
+ // clear it. A document pinned to its version has no head to return to
1136
+ // and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
1137
+ if (doc && doc.version !== null) {
1138
+ void attempt(doc, () => doc.returnToHead()).catch(() => { });
1139
+ }
1005
1140
  hideBar();
1006
1141
  },
1007
1142
  };
1008
1143
  const render = () => {
1009
1144
  const doc = session.document;
1010
1145
  const docId = doc?.id ?? null;
1011
- // A new active document (or none) ends any prior browsing session.
1146
+ // A new active document (or none) ends any prior browsing session. Keyed
1147
+ // on the id rather than the Document instance because a checkout keeps the
1148
+ // instance and only moves its `version`, so the bar follows the document
1149
+ // the user is stepping through rather than closing under them.
1012
1150
  if (docId !== lastDocId) {
1013
1151
  lastDocId = docId;
1014
1152
  browsing = false;
1153
+ dismissedAt = undefined;
1015
1154
  }
1016
1155
  // A live checkout (`doc.version !== null`) starts / keeps the browsing
1017
1156
  // session. Other read-only states (a view-only share link, a still-loading
1018
1157
  // doc) are not this bar's concern, and `doc.id` is null only on the offline
1019
1158
  // / test path the embed can't serve.
1159
+ // `versionBrowsing` is set by the reader asking for this surface, and the
1160
+ // close cleared it, so it names a fresh request whatever version the
1161
+ // document is on and outranks the dismissal.
1020
1162
  if (doc != null &&
1021
1163
  docId !== null &&
1022
- (doc.version !== null || doc.versionBrowsing)) {
1164
+ (doc.version !== null || doc.versionBrowsing) &&
1165
+ (doc.versionBrowsing || doc.version !== dismissedAt)) {
1023
1166
  browsing = true;
1167
+ dismissedAt = undefined;
1168
+ // Mirror the open bar onto the document, which is what makes it
1169
+ // read-only for as long as the bar is up. A preview entered by picking
1170
+ // a version sets only `version`, so without this the reader who then
1171
+ // returns to latest keeps the bar and gets the document back, which is
1172
+ // the pair the flag exists to keep together.
1173
+ doc._setVersionBrowsing(true);
1024
1174
  }
1025
1175
  // The bar stays open for the whole session, including at the live head
1026
1176
  // (`doc.version === null`, browsing still true), until `close`.
@@ -1033,6 +1183,7 @@ function buildVersionPreview(deps) {
1033
1183
  branch: doc.branchId ?? undefined,
1034
1184
  version: doc.version,
1035
1185
  branchName: doc._currentBranchName() ?? undefined,
1186
+ pinned: doc._isVersionPinned,
1036
1187
  });
1037
1188
  }
1038
1189
  else {