@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.
Files changed (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  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 +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
@@ -16,10 +16,13 @@
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
- import type { DocumentDriveStatus } from './document-signals.js';
22
+ import type {
23
+ DocumentDriveStatus,
24
+ DrivePublishTarget,
25
+ } from './document-signals.js';
23
26
  import {
24
27
  createVersionBarFrame,
25
28
  type VersionBarDeps,
@@ -59,9 +62,6 @@ export interface VersionPreviewDeps {
59
62
  dashboardBaseUrl: string;
60
63
  // Pins the embed's light/dark theme (matches the other hosted surfaces).
61
64
  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
65
  }
66
66
 
67
67
  export interface AccountMenuDeps {
@@ -69,9 +69,9 @@ export interface AccountMenuDeps {
69
69
  onUserChange: (listener: (user: BrassUser | null) => void) => () => void;
70
70
  signIn: () => void;
71
71
  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.
72
+ // Base URL of the Brass dashboard, for the account menu's "Open Brass
73
+ // dashboard" link. The client's resolved dashboard origin (picks the
74
+ // environment), with no trailing slash.
75
75
  dashboardBaseUrl: string;
76
76
  }
77
77
 
@@ -254,7 +254,30 @@ const CSS = `
254
254
  fallback shown when no image is set or the image fails to load. */
255
255
  .b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
256
256
  .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; }
257
+ /* The account menu opens on the identity it acts for: the avatar again at
258
+ reading size, the name, and the email under it. The rows below are the
259
+ actions on that identity, so they carry a glyph and sit apart from the
260
+ header behind a rule. */
261
+ .b-account .b-pop { min-width:236px; max-width:min(300px, calc(100vw - 16px)); }
262
+ .b-account-head { display:flex; align-items:center; gap:10px; padding:6px 8px 8px; }
263
+ .b-account-face { flex:0 0 auto; width:36px; height:36px; border-radius:50%;
264
+ display:inline-flex; align-items:center; justify-content:center; overflow:hidden;
265
+ background:var(--brass-accent); color:var(--brass-accent-fg);
266
+ font-size:15px; font-weight:600; }
267
+ /* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
268
+ menu: a grid item's default min-width is its content's intrinsic size. */
269
+ .b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
270
+ .b-account-name, .b-email { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
271
+ .b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
272
+ .b-email { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
273
+ .b-account .b-pop button, .b-account .b-pop a {
274
+ display:flex; align-items:center; gap:10px; font-size:13px; }
275
+ .b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
276
+ .b-account .b-pop button:hover svg, .b-account .b-pop a:hover svg { color:var(--brass-fg); }
277
+ /* Restated at this specificity so the flex rows above don't unhide a row the
278
+ guest / signed-in swap hides. */
279
+ .b-account .b-pop button[hidden], .b-account .b-pop a[hidden],
280
+ .b-account .b-pop .b-account-head[hidden], .b-account .b-pop .b-sep[hidden] { display:none; }
258
281
  /* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
259
282
  there is something to do. Clicking opens a top-layer Popover (never clipped
260
283
  by the header overflow, light-dismissed on outside tap). */
@@ -289,6 +312,10 @@ const CSS = `
289
312
  .b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
290
313
  .b-drive-source:hover { text-decoration:underline; }
291
314
  .b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
315
+ /* The drive rejecting a published file is the one line in this panel that
316
+ reports a problem, so it carries the danger tone rather than the muted one
317
+ every other line uses. */
318
+ .b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
292
319
  /* Historical-version preview bar: a full-width strip the toolbar shows below
293
320
  the bar whenever the active doc is previewing an earlier version. The strip
294
321
  itself is the platform-served version-bar iframe (the SDK owns only this
@@ -304,6 +331,23 @@ const CSS = `
304
331
  background:var(--brass-surface-2); color:var(--brass-fg-muted);
305
332
  border-bottom:1px solid var(--brass-border); }
306
333
  .b-version iframe { display:block; }
334
+ /* The SDK-drawn strip that stands in when the embed reports it cannot render.
335
+ Same box as the placeholder, plus the two controls that need no version
336
+ timeline, so returning to the live head stays reachable. */
337
+ .b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
338
+ min-height:44px; padding:0 14px; font-size:13px;
339
+ background:var(--brass-surface-2); color:var(--brass-fg-muted);
340
+ border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
341
+ .b-version-fallback[hidden] { display:none; }
342
+ /* Takes the strip's slack, so the controls hold the trailing edge whichever
343
+ of them is showing, and it is the only part that ellipsizes. */
344
+ .b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
345
+ text-overflow:ellipsis; white-space:nowrap; }
346
+ .b-version-fallback button { flex:0 0 auto; background:transparent;
347
+ border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
348
+ color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
349
+ .b-version-fallback button:hover { background:var(--brass-surface); }
350
+ .b-version-fallback button[hidden] { display:none; }
307
351
  /* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
308
352
  (negative outline-offset), because the left cluster and the title row clip
309
353
  their overflow so a long name ellipsizes: a ring outside the box falls in
@@ -349,6 +393,43 @@ function initialOf(user: BrassUser): string {
349
393
  return label.trim().charAt(0).toUpperCase() || '?';
350
394
  }
351
395
 
396
+ // A user's face: the uploaded image over a monogram. The monogram is the
397
+ // fallback, shown when the user has no avatar image and restored if the image
398
+ // fails to load (a broken / unreachable URL renders the initial, never a
399
+ // broken-image glyph). The caller places the pair inside its own circle, so
400
+ // the account menu's trigger and its header draw the same face at two sizes.
401
+ function buildAvatarFace(): {
402
+ nodes: [HTMLImageElement, HTMLSpanElement];
403
+ render(user: BrassUser | null): void;
404
+ } {
405
+ const img = el('img', 'b-avatar-img');
406
+ img.alt = '';
407
+ const initial = el('span', 'b-avatar-initial');
408
+ const showMonogram = (): void => {
409
+ img.hidden = true;
410
+ initial.hidden = false;
411
+ };
412
+ img.addEventListener('error', () => {
413
+ img.removeAttribute('src');
414
+ showMonogram();
415
+ });
416
+ return {
417
+ nodes: [img, initial],
418
+ render(user: BrassUser | null): void {
419
+ initial.textContent = user ? initialOf(user) : '?';
420
+ const url = user?.avatar_url?.trim() ?? '';
421
+ if (!url) {
422
+ img.removeAttribute('src');
423
+ showMonogram();
424
+ return;
425
+ }
426
+ if (img.getAttribute('src') !== url) img.src = url;
427
+ img.hidden = false;
428
+ initial.hidden = true;
429
+ },
430
+ };
431
+ }
432
+
352
433
  function renderCommandButton(cmd: Command, asTool: boolean): HTMLButtonElement {
353
434
  const b = el('button', asTool ? 'b-tool' : undefined);
354
435
  b.type = 'button';
@@ -584,7 +665,11 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
584
665
  loc.hidden = true;
585
666
  root.append(titleRow, loc);
586
667
 
587
- let lastDocId: string | null = null;
668
+ // The document the location chip describes, held by INSTANCE. A branch
669
+ // switch, and a re-open after the document moves, bind a new Document under
670
+ // the same id, and a move is exactly the event that changes the location
671
+ // label, so an id comparison would leave the chip naming the old project.
672
+ let boundDoc: DocumentSession['document'] = null;
588
673
  // The non-default branch label (null on the default), read by `fitBranch`
589
674
  // so a resize re-evaluates the drop without a full re-render.
590
675
  let branchLabel: string | null = null;
@@ -608,8 +693,9 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
608
693
  return deps.session.document?._currentBranchName() ?? null;
609
694
  }
610
695
 
611
- async function refreshLocation(): Promise<void> {
612
- const doc = deps.session.document;
696
+ // Resolves the label for one document, named by the caller so the in-flight
697
+ // fetch is attributed to the Document it was started for.
698
+ async function refreshLocation(doc: DocumentSession['document']): Promise<void> {
613
699
  const docId = doc?.id ?? null;
614
700
  if (!docId) {
615
701
  loc.hidden = true;
@@ -617,7 +703,7 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
617
703
  }
618
704
  try {
619
705
  const record = await deps.getDocument(docId);
620
- if (deps.session.document?.id !== docId) return; // superseded
706
+ if (deps.session.document !== doc) return; // superseded
621
707
  // The server hands back an opaque location label (rendered verbatim);
622
708
  // the toolbar never composes it from project / org ids, so the
623
709
  // hierarchy above the document can evolve without a client change. A
@@ -633,14 +719,14 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
633
719
  }
634
720
  loc.hidden = false;
635
721
  } catch {
636
- if (deps.session.document?.id === docId) loc.hidden = true;
722
+ if (deps.session.document === doc) loc.hidden = true;
637
723
  }
638
724
  }
639
725
 
640
726
  function runManage(): void {
641
727
  void (async () => {
642
728
  await deps.session.run('manage');
643
- void refreshLocation();
729
+ void refreshLocation(deps.session.document);
644
730
  })();
645
731
  }
646
732
  title.onclick = runManage;
@@ -675,11 +761,10 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
675
761
  branch.title = `On branch "${branchLabel}"`;
676
762
  }
677
763
  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();
764
+ // Re-resolve location only when the bound document changes.
765
+ if (doc !== boundDoc) {
766
+ boundDoc = doc;
767
+ void refreshLocation(doc);
683
768
  }
684
769
  };
685
770
  render();
@@ -728,7 +813,12 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
728
813
  injectStyles(document);
729
814
  const root = el('div', 'brass-ui b-roster');
730
815
 
731
- let lastDocId: string | null = null;
816
+ // The document whose presence client is subscribed, held by INSTANCE.
817
+ // Presence is scoped to a (document, branch) pair, and a branch switch binds
818
+ // a new Document under the SAME id, so an id comparison would keep the
819
+ // roster subscribed to the branch the user just left and show the wrong
820
+ // people as present on the one they are looking at.
821
+ let boundDoc: DocumentSession['document'] = null;
732
822
  let presenceOff: (() => void) | null = null;
733
823
 
734
824
  const renderRoster = (participants: readonly PresenceParticipant[]): void => {
@@ -773,9 +863,9 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
773
863
  }
774
864
 
775
865
  const render = (): void => {
776
- const docId = deps.session.document?.id ?? null;
777
- if (docId !== lastDocId) {
778
- lastDocId = docId;
866
+ const doc = deps.session.document;
867
+ if (doc !== boundDoc) {
868
+ boundDoc = doc;
779
869
  bindPresence();
780
870
  }
781
871
  };
@@ -790,10 +880,10 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
790
880
  };
791
881
  }
792
882
 
793
- // A monochrome chain-link glyph (inherits `color` via `currentColor`). Built
883
+ // A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
794
884
  // in the SVG namespace because `createElement('svg')` yields an HTML element
795
885
  // that won't render.
796
- function chainLinkIcon(): SVGSVGElement {
886
+ function strokeIcon(...paths: string[]): SVGSVGElement {
797
887
  const ns = 'http://www.w3.org/2000/svg';
798
888
  const svg = document.createElementNS(ns, 'svg');
799
889
  svg.setAttribute('width', '16');
@@ -805,10 +895,7 @@ function chainLinkIcon(): SVGSVGElement {
805
895
  svg.setAttribute('stroke-linecap', 'round');
806
896
  svg.setAttribute('stroke-linejoin', 'round');
807
897
  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
- ]) {
898
+ for (const d of paths) {
812
899
  const path = document.createElementNS(ns, 'path');
813
900
  path.setAttribute('d', d);
814
901
  svg.append(path);
@@ -816,11 +903,34 @@ function chainLinkIcon(): SVGSVGElement {
816
903
  return svg;
817
904
  }
818
905
 
906
+ function chainLinkIcon(): SVGSVGElement {
907
+ return strokeIcon(
908
+ 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
909
+ 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
910
+ );
911
+ }
912
+
819
913
  // How long the popover holds its "Publishing..." affordance after a push
820
914
  // before giving up on the synced confirmation. Long enough to cover a normal
821
915
  // drive webhook round-trip.
822
916
  const PUBLISH_HOLD_MS = 30000;
823
917
 
918
+ // `value` when it is an absolute http(s) URL, else null. The drive-sync
919
+ // popover's "View in <drive>" link is the one place the toolbar puts a
920
+ // server-supplied string into an `href`, and an href runs whatever scheme it
921
+ // names: a `javascript:` URL executes in the app's own page on the click.
922
+ // A relative URL throws out of `new URL` and is refused with the rest, which
923
+ // is right here, since the link points at an external drive item.
924
+ function httpUrlOrNull(value: string): string | null {
925
+ let url: URL;
926
+ try {
927
+ url = new URL(value);
928
+ } catch {
929
+ return null;
930
+ }
931
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
932
+ }
933
+
824
934
  // The drive-sync indicator: a link icon (dim at rest, accent / danger with a
825
935
  // dot when there is something to do) whose popover describes the state
826
936
  // (server-authored copy, verbatim) and offers publish / resolve. The receiver
@@ -849,8 +959,13 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
849
959
  dot.setAttribute('aria-hidden', 'true');
850
960
  button.append(dot);
851
961
 
852
- let lastDocId: string | null = null;
962
+ // The document whose drive status is subscribed, held by INSTANCE. A branch
963
+ // switch binds a new Document under the same id, and each carries its own
964
+ // drive state, so an id comparison would leave the indicator describing the
965
+ // document that was closed.
966
+ let boundDoc: DocumentSession['document'] = null;
853
967
  let statusOff: (() => void) | null = null;
968
+ let publishOff: (() => void) | null = null;
854
969
 
855
970
  // A publish runs on a server worker and reaches `synced` only after the
856
971
  // worker (and, on a file-roundtrip, the drive's importer) confirms. In that
@@ -864,24 +979,111 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
864
979
  let publishing = false;
865
980
  let publishError = false;
866
981
  let publishTimer: ReturnType<typeof setTimeout> | null = null;
982
+ // Set on a click, cleared by the next publish-state frame. Between the two,
983
+ // the held state describes the PREVIOUS attempt, and rendering it would
984
+ // report the last failure over the publish just started.
985
+ let publishStateStale = false;
867
986
  let currentStatus: DocumentDriveStatus | null = null;
868
987
  const clearPublishing = (): void => {
869
988
  publishing = false;
989
+ // The stale mark is cleared with the hold it belongs to. It is otherwise
990
+ // cleared only by a publish-state frame, so a publish that draws none
991
+ // would leave the panel reading the target's resting sentence for the
992
+ // life of the document.
993
+ publishStateStale = false;
870
994
  if (publishTimer !== null) {
871
995
  clearTimeout(publishTimer);
872
996
  publishTimer = null;
873
997
  }
874
998
  };
875
999
 
1000
+ // The panel for a document with no source item: the server's sentence about
1001
+ // where the file lands, and the publish that puts it there. The first
1002
+ // publish creates the artifact and makes the document its mirror, at which
1003
+ // point the drive status arrives and the mirror panel above replaces this
1004
+ // one.
1005
+ const renderPublishOffer = (target: DrivePublishTarget): void => {
1006
+ root.hidden = false;
1007
+ // A document the drive has never received is as far out of sync as one
1008
+ // whose edits have not been published, so the icon carries the same
1009
+ // attention an `ahead` document's does. What separates them is what the
1010
+ // panel offers, not how much the reader is told to look.
1011
+ root.classList.add('has-changes');
1012
+ root.classList.remove('is-broken');
1013
+ const state = publishStateStale ? null : (boundDoc?._publishState ?? null);
1014
+ const inFlight =
1015
+ publishing || state?.publishing === true || target.publishing;
1016
+ button.title = inFlight
1017
+ ? 'Publishing...'
1018
+ : state?.failed
1019
+ ? `Publish to ${target.driveName} failed`
1020
+ : `Publish to ${target.driveName}`;
1021
+
1022
+ const failed =
1023
+ !inFlight && (state?.failed === true || target.publishFailed === true);
1024
+
1025
+ const rows: HTMLElement[] = [];
1026
+ // One line, server-authored either way: the worker's phase copy while it
1027
+ // runs, its account of the failure when it stopped, and the resting
1028
+ // sentence otherwise. Marked as a problem when it is one, since nothing
1029
+ // else in this panel distinguishes a document whose publish failed from
1030
+ // one that has simply never published.
1031
+ const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
1032
+ p.textContent = inFlight
1033
+ ? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
1034
+ : (state?.detail ?? target.detail);
1035
+ rows.push(p);
1036
+
1037
+ const pub = el('button');
1038
+ pub.type = 'button';
1039
+ pub.disabled = inFlight;
1040
+ pub.textContent = inFlight
1041
+ ? 'Publishing...'
1042
+ : publishError || failed
1043
+ ? `Publish to ${target.driveName}, retry`
1044
+ : `Publish to ${target.driveName}`;
1045
+ pub.onclick = () => {
1046
+ const doc = deps.session.document;
1047
+ if (!doc) return;
1048
+ publishError = false;
1049
+ publishing = true;
1050
+ publishStateStale = true;
1051
+ // The same safety net the mirror panel keeps: a publish that neither
1052
+ // errors nor lands leaves the panel offering the action again rather
1053
+ // than held forever.
1054
+ publishTimer = setTimeout(() => {
1055
+ clearPublishing();
1056
+ render(currentStatus);
1057
+ }, PUBLISH_HOLD_MS);
1058
+ render(currentStatus);
1059
+ void doc.pushToDrive().catch(() => {
1060
+ clearPublishing();
1061
+ publishError = true;
1062
+ render(currentStatus);
1063
+ });
1064
+ };
1065
+ rows.push(pub);
1066
+ pop.replaceChildren(...rows);
1067
+ };
1068
+
876
1069
  const render = (status: DocumentDriveStatus | null): void => {
877
1070
  currentStatus = status;
878
- // Hidden only when the doc is not drive-linked.
1071
+ // A document that mirrors nothing can still have somewhere to go: its
1072
+ // project may be linked to a drive that takes it as a new file, which is
1073
+ // what an upload into a linked project sits waiting for. Offer that
1074
+ // publish here, so an app gets it by mounting this piece rather than by
1075
+ // building a surface of its own.
879
1076
  if (status === null) {
880
- root.hidden = true;
881
- menu.close();
882
- clearPublishing();
883
- root.classList.remove('has-changes', 'is-broken');
884
- pop.replaceChildren();
1077
+ const target = boundDoc?._publishTarget ?? null;
1078
+ if (target === null) {
1079
+ root.hidden = true;
1080
+ menu.close();
1081
+ clearPublishing();
1082
+ root.classList.remove('has-changes', 'is-broken');
1083
+ pop.replaceChildren();
1084
+ return;
1085
+ }
1086
+ renderPublishOffer(target);
885
1087
  return;
886
1088
  }
887
1089
  // The publish settles once the doc actually reaches synced; drop the
@@ -916,10 +1118,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
916
1118
  // there is one), then the commands. Rebuilt on every status change, so an
917
1119
  // open popover updates live.
918
1120
  const rows: HTMLElement[] = [];
919
- if (status.itemUrl) {
1121
+ const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
1122
+ if (itemUrl) {
920
1123
  const head = el('div', 'b-drive-head');
921
1124
  const link = el('a', 'b-drive-source');
922
- link.href = status.itemUrl;
1125
+ link.href = itemUrl;
923
1126
  link.target = '_blank';
924
1127
  link.rel = 'noopener noreferrer';
925
1128
  link.textContent = `View in ${driveName}`;
@@ -946,6 +1149,15 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
946
1149
  rows.push(p);
947
1150
  }
948
1151
 
1152
+ // The drive could not read what the last publish delivered. Shown in the
1153
+ // drive's own words, above the commands: the publish succeeded, so
1154
+ // nothing else in this panel says anything is wrong.
1155
+ if (status.sourceUnreadable) {
1156
+ const warn = el('p', 'b-drive-unreadable');
1157
+ warn.textContent = status.sourceUnreadable;
1158
+ rows.push(warn);
1159
+ }
1160
+
949
1161
  if (isPublishing) {
950
1162
  // Held while the publish is in flight (a local click hold, or the doc's
951
1163
  // pending work-status), until it reaches synced (see `isPublishing`).
@@ -955,8 +1167,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
955
1167
  pub.disabled = true;
956
1168
  rows.push(pub);
957
1169
  } else if (status.canPush) {
958
- // `canPush` already encodes two-way + not broken + local edits, so this
959
- // never offers a no-op publish.
1170
+ // `canPush` already encodes two-way + not broken + local edits + a
1171
+ // server-writable format, so this never offers a publish the server
1172
+ // would refuse.
960
1173
  const pub = el('button');
961
1174
  pub.type = 'button';
962
1175
  pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
@@ -1004,6 +1217,8 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1004
1217
  function bind(): void {
1005
1218
  statusOff?.();
1006
1219
  statusOff = null;
1220
+ publishOff?.();
1221
+ publishOff = null;
1007
1222
  const doc = deps.session.document;
1008
1223
  if (!doc) {
1009
1224
  render(null);
@@ -1014,12 +1229,23 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1014
1229
  // started from any surface (the dashboard card, the app) flips it and the
1015
1230
  // indicator holds "Publishing..." without a local click.
1016
1231
  statusOff = doc.onDriveStatus(render);
1232
+ // The same signal for a document that mirrors nothing yet, which has no
1233
+ // drive status to carry it: its first publish is the one that creates the
1234
+ // artifact, and this is how its progress and its failure arrive.
1235
+ publishOff = doc._onPublishState(() => {
1236
+ publishStateStale = false;
1237
+ // A publish the server has finished with ends the local click hold:
1238
+ // holding "Publishing..." for the rest of it would report a push that
1239
+ // is over as one still running.
1240
+ if (!doc._publishState.publishing) clearPublishing();
1241
+ render(currentStatus);
1242
+ });
1017
1243
  }
1018
1244
 
1019
1245
  const onSession = (): void => {
1020
- const docId = deps.session.document?.id ?? null;
1021
- if (docId !== lastDocId) {
1022
- lastDocId = docId;
1246
+ const doc = deps.session.document;
1247
+ if (doc !== boundDoc) {
1248
+ boundDoc = doc;
1023
1249
  bind();
1024
1250
  }
1025
1251
  };
@@ -1030,6 +1256,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1030
1256
  destroy(): void {
1031
1257
  unsub();
1032
1258
  statusOff?.();
1259
+ publishOff?.();
1033
1260
  clearPublishing();
1034
1261
  menu.destroy();
1035
1262
  },
@@ -1044,18 +1271,41 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1044
1271
  label: 'Account',
1045
1272
  align: 'end',
1046
1273
  });
1274
+ // The menu opens on the identity it acts for: the avatar at reading size,
1275
+ // the display name, and the email under it. The name line drops out when the
1276
+ // user has none, leaving the email as the single identifying line.
1277
+ const head = el('div', 'b-account-head');
1278
+ const headFace = buildAvatarFace();
1279
+ const faceEl = el('span', 'b-account-face');
1280
+ faceEl.append(...headFace.nodes);
1281
+ const nameEl = el('div', 'b-account-name');
1047
1282
  const emailEl = el('div', 'b-email');
1283
+ const identity = el('div', 'b-account-id');
1284
+ identity.append(nameEl, emailEl);
1285
+ head.append(faceEl, identity);
1286
+ const headSep = el('hr', 'b-sep');
1287
+ headSep.setAttribute('role', 'separator');
1048
1288
  // Opens the dashboard in a new tab, so the user keeps the app open in the
1049
1289
  // current one. `/home` is the authenticated landing (resolves the active
1050
1290
  // org); the bare origin is the sign-in page.
1051
1291
  const dashboardLink = el('a');
1052
- dashboardLink.textContent = 'Open dashboard';
1292
+ dashboardLink.append(
1293
+ strokeIcon(
1294
+ 'M14 3h7v7',
1295
+ 'M10 14 21 3',
1296
+ 'M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5',
1297
+ ),
1298
+ 'Open Brass dashboard',
1299
+ );
1053
1300
  dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
1054
1301
  dashboardLink.target = '_blank';
1055
1302
  dashboardLink.rel = 'noopener noreferrer';
1056
1303
  dashboardLink.onclick = () => menu.close();
1057
1304
  const signOutBtn = el('button');
1058
- signOutBtn.textContent = 'Sign out';
1305
+ signOutBtn.append(
1306
+ strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'),
1307
+ 'Sign out',
1308
+ );
1059
1309
  signOutBtn.onclick = () => {
1060
1310
  menu.close();
1061
1311
  deps.signOut();
@@ -1065,50 +1315,37 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1065
1315
  // meaningless, so the menu offers a single "Sign in" that takes the top
1066
1316
  // window through the app's normal sign-in and returns to this URL.
1067
1317
  const signInBtn = el('button');
1068
- signInBtn.textContent = 'Sign in';
1318
+ signInBtn.append(
1319
+ strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'),
1320
+ 'Sign in',
1321
+ );
1069
1322
  signInBtn.onclick = () => {
1070
1323
  menu.close();
1071
1324
  deps.signIn();
1072
1325
  };
1073
- menu.pop.append(emailEl, dashboardLink, signOutBtn, signInBtn);
1326
+ menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
1074
1327
 
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
- });
1328
+ const triggerFace = buildAvatarFace();
1329
+ menu.trigger.append(...triggerFace.nodes);
1092
1330
 
1093
1331
  const renderUser = (user: BrassUser | null): void => {
1094
- initialEl.textContent = user ? initialOf(user) : '?';
1095
- emailEl.textContent = user ? (user.email ?? user.display_name ?? user.sub) : '';
1332
+ triggerFace.render(user);
1333
+ headFace.render(user);
1334
+ nameEl.textContent = user?.display_name ?? '';
1335
+ nameEl.hidden = !user?.display_name;
1336
+ emailEl.textContent = user ? (user.email ?? user.sub) : '';
1337
+ // The email ellipsizes to hold the menu's width, so the untruncated
1338
+ // address stays reachable on hover.
1339
+ emailEl.title = emailEl.textContent;
1096
1340
  // Swap the menu body between the signed-in actions and the guest's lone
1097
1341
  // "Sign in": a null user is an anonymous link holder.
1098
1342
  const signedIn = user !== null;
1343
+ head.hidden = !signedIn;
1344
+ headSep.hidden = !signedIn;
1099
1345
  emailEl.hidden = !signedIn;
1100
1346
  dashboardLink.hidden = !signedIn;
1101
1347
  signOutBtn.hidden = !signedIn;
1102
1348
  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
1349
  };
1113
1350
  renderUser(deps.getUser());
1114
1351
  const unsub = deps.onUserChange(renderUser);
@@ -1144,6 +1381,14 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1144
1381
  // closes it, when the active document changes, or when no doc is open.
1145
1382
  let browsing = false;
1146
1383
  let lastDocId: string | null = null;
1384
+ // The version the reader dismissed the bar at, when the document was left
1385
+ // holding one. A document that cannot return to its head (one pinned to a
1386
+ // version) stays on it, so without this the next render reads a non-null
1387
+ // `doc.version` and latches the bar the reader just dismissed straight back
1388
+ // on. A dismissal at the live head records nothing: there is no version
1389
+ // left to latch on, and the head is where the next browse-in-the-app
1390
+ // request arrives, so a record there would swallow it.
1391
+ let dismissedAt: number | undefined = undefined;
1147
1392
 
1148
1393
  const session = deps.session;
1149
1394
  const hideBar = (): void => {
@@ -1153,27 +1398,44 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1153
1398
  frame = null;
1154
1399
  }
1155
1400
  };
1401
+ // A failed intent reaches the reader through the bar (which reverts to the
1402
+ // document's real state) and the developer through `Brass.onError`. Both
1403
+ // halves matter: the bar moves optimistically, so a swallowed failure is a
1404
+ // bar naming a version nothing is showing.
1405
+ const attempt = async (
1406
+ doc: BrassDocument,
1407
+ act: () => void | Promise<void>,
1408
+ ): Promise<void> => {
1409
+ try {
1410
+ await act();
1411
+ } catch (err) {
1412
+ doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
1413
+ throw err;
1414
+ }
1415
+ };
1156
1416
  const frameDeps: VersionBarDeps = {
1157
1417
  dashboardBaseUrl: deps.dashboardBaseUrl,
1158
1418
  ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
1159
- ...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
1160
1419
  // Read the live active document at call time: a bridge intent lands
1161
1420
  // async, so it must not close over a doc that may have been superseded.
1162
- checkoutVersion: (version) => {
1163
- void session.document?._checkoutVersion(version).catch(() => {});
1421
+ currentVersion: () => session.document?.version ?? null,
1422
+ checkoutVersion: async (version) => {
1423
+ const doc = session.document;
1424
+ if (!doc) return;
1425
+ await attempt(doc, () => doc._checkoutVersion(version));
1164
1426
  },
1165
- returnToHead: () => {
1427
+ returnToHead: async () => {
1166
1428
  // Return to the live head but keep the bar open (the browsing session
1167
1429
  // persists); the bar re-renders in its "at latest" state with a Close
1168
1430
  // 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
- }
1431
+ const doc = session.document;
1432
+ if (!doc) return;
1433
+ await attempt(doc, () => doc.returnToHead());
1174
1434
  },
1175
1435
  fork: (fromVersion, name) => {
1176
- void session.document?._forkFromVersion(fromVersion, name).catch(() => {});
1436
+ const doc = session.document;
1437
+ if (!doc) return;
1438
+ void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => {});
1177
1439
  },
1178
1440
  browse: () => {
1179
1441
  void session.run('history');
@@ -1182,8 +1444,28 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1182
1444
  // Explicit dismissal: end the browsing session and hide the bar
1183
1445
  // synchronously so the tap is instant. The document's own browsing
1184
1446
  // flag is cleared too, or the next render would latch the bar back on.
1447
+ const doc = session.document;
1185
1448
  browsing = false;
1186
- session.document?._setVersionBrowsing(false);
1449
+ dismissedAt = doc?.version ?? undefined;
1450
+ doc?._setVersionBrowsing(false);
1451
+ // Leaving the bar means leaving the preview: the bar is the only
1452
+ // surface that returns a document to its head, so a dismissal that
1453
+ // left it checked out would leave an app read-only with nothing to
1454
+ // clear it. A document pinned to its version has no head to return to
1455
+ // and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
1456
+ // Once the return lands, the dismissal has nothing left to hold
1457
+ // against: the doc is live, and a LATER pick of the same version in
1458
+ // the history dialog is a fresh preview that must latch the bar (its
1459
+ // pick sets only `version`, so a stale `dismissedAt` equal to it would
1460
+ // otherwise leave the reader checked out, read-only, with no bar and
1461
+ // no exit).
1462
+ if (doc && doc.version !== null) {
1463
+ void attempt(doc, () => doc.returnToHead())
1464
+ .then(() => {
1465
+ if (session.document === doc) dismissedAt = undefined;
1466
+ })
1467
+ .catch(() => {});
1468
+ }
1187
1469
  hideBar();
1188
1470
  },
1189
1471
  };
@@ -1191,21 +1473,36 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1191
1473
  const render = (): void => {
1192
1474
  const doc = session.document;
1193
1475
  const docId = doc?.id ?? null;
1194
- // A new active document (or none) ends any prior browsing session.
1476
+ // A new active document (or none) ends any prior browsing session. Keyed
1477
+ // on the id rather than the Document instance because a checkout keeps the
1478
+ // instance and only moves its `version`, so the bar follows the document
1479
+ // the user is stepping through rather than closing under them.
1195
1480
  if (docId !== lastDocId) {
1196
1481
  lastDocId = docId;
1197
1482
  browsing = false;
1483
+ dismissedAt = undefined;
1198
1484
  }
1199
1485
  // A live checkout (`doc.version !== null`) starts / keeps the browsing
1200
1486
  // session. Other read-only states (a view-only share link, a still-loading
1201
1487
  // doc) are not this bar's concern, and `doc.id` is null only on the offline
1202
1488
  // / test path the embed can't serve.
1489
+ // `versionBrowsing` is set by the reader asking for this surface, and the
1490
+ // close cleared it, so it names a fresh request whatever version the
1491
+ // document is on and outranks the dismissal.
1203
1492
  if (
1204
1493
  doc != null &&
1205
1494
  docId !== null &&
1206
- (doc.version !== null || doc.versionBrowsing)
1495
+ (doc.version !== null || doc.versionBrowsing) &&
1496
+ (doc.versionBrowsing || doc.version !== dismissedAt)
1207
1497
  ) {
1208
1498
  browsing = true;
1499
+ dismissedAt = undefined;
1500
+ // Mirror the open bar onto the document, which is what makes it
1501
+ // read-only for as long as the bar is up. A preview entered by picking
1502
+ // a version sets only `version`, so without this the reader who then
1503
+ // returns to latest keeps the bar and gets the document back, which is
1504
+ // the pair the flag exists to keep together.
1505
+ doc._setVersionBrowsing(true);
1209
1506
  }
1210
1507
  // The bar stays open for the whole session, including at the live head
1211
1508
  // (`doc.version === null`, browsing still true), until `close`.
@@ -1217,6 +1514,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1217
1514
  branch: doc.branchId ?? undefined,
1218
1515
  version: doc.version,
1219
1516
  branchName: doc._currentBranchName() ?? undefined,
1517
+ pinned: doc._isVersionPinned,
1220
1518
  });
1221
1519
  } else {
1222
1520
  hideBar();