@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
@@ -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). */
@@ -179,6 +202,10 @@ const CSS = `
179
202
  .b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
180
203
  .b-drive-source:hover { text-decoration:underline; }
181
204
  .b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
205
+ /* The drive rejecting a published file is the one line in this panel that
206
+ reports a problem, so it carries the danger tone rather than the muted one
207
+ every other line uses. */
208
+ .b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
182
209
  /* Historical-version preview bar: a full-width strip the toolbar shows below
183
210
  the bar whenever the active doc is previewing an earlier version. The strip
184
211
  itself is the platform-served version-bar iframe (the SDK owns only this
@@ -194,6 +221,23 @@ const CSS = `
194
221
  background:var(--brass-surface-2); color:var(--brass-fg-muted);
195
222
  border-bottom:1px solid var(--brass-border); }
196
223
  .b-version iframe { display:block; }
224
+ /* The SDK-drawn strip that stands in when the embed reports it cannot render.
225
+ Same box as the placeholder, plus the two controls that need no version
226
+ timeline, so returning to the live head stays reachable. */
227
+ .b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
228
+ min-height:44px; padding:0 14px; font-size:13px;
229
+ background:var(--brass-surface-2); color:var(--brass-fg-muted);
230
+ border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
231
+ .b-version-fallback[hidden] { display:none; }
232
+ /* Takes the strip's slack, so the controls hold the trailing edge whichever
233
+ of them is showing, and it is the only part that ellipsizes. */
234
+ .b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
235
+ text-overflow:ellipsis; white-space:nowrap; }
236
+ .b-version-fallback button { flex:0 0 auto; background:transparent;
237
+ border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
238
+ color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
239
+ .b-version-fallback button:hover { background:var(--brass-surface); }
240
+ .b-version-fallback button[hidden] { display:none; }
197
241
  /* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
198
242
  (negative outline-offset), because the left cluster and the title row clip
199
243
  their overflow so a long name ellipsizes: a ring outside the box falls in
@@ -234,6 +278,40 @@ function initialOf(user) {
234
278
  const label = user.display_name ?? user.email ?? user.sub;
235
279
  return label.trim().charAt(0).toUpperCase() || '?';
236
280
  }
281
+ // A user's face: the uploaded image over a monogram. The monogram is the
282
+ // fallback, shown when the user has no avatar image and restored if the image
283
+ // fails to load (a broken / unreachable URL renders the initial, never a
284
+ // broken-image glyph). The caller places the pair inside its own circle, so
285
+ // the account menu's trigger and its header draw the same face at two sizes.
286
+ function buildAvatarFace() {
287
+ const img = el('img', 'b-avatar-img');
288
+ img.alt = '';
289
+ const initial = el('span', 'b-avatar-initial');
290
+ const showMonogram = () => {
291
+ img.hidden = true;
292
+ initial.hidden = false;
293
+ };
294
+ img.addEventListener('error', () => {
295
+ img.removeAttribute('src');
296
+ showMonogram();
297
+ });
298
+ return {
299
+ nodes: [img, initial],
300
+ render(user) {
301
+ initial.textContent = user ? initialOf(user) : '?';
302
+ const url = user?.avatar_url?.trim() ?? '';
303
+ if (!url) {
304
+ img.removeAttribute('src');
305
+ showMonogram();
306
+ return;
307
+ }
308
+ if (img.getAttribute('src') !== url)
309
+ img.src = url;
310
+ img.hidden = false;
311
+ initial.hidden = true;
312
+ },
313
+ };
314
+ }
237
315
  function renderCommandButton(cmd, asTool) {
238
316
  const b = el('button', asTool ? 'b-tool' : undefined);
239
317
  b.type = 'button';
@@ -425,7 +503,11 @@ function buildDocumentTitle(deps) {
425
503
  loc.type = 'button';
426
504
  loc.hidden = true;
427
505
  root.append(titleRow, loc);
428
- let lastDocId = null;
506
+ // The document the location chip describes, held by INSTANCE. A branch
507
+ // switch, and a re-open after the document moves, bind a new Document under
508
+ // the same id, and a move is exactly the event that changes the location
509
+ // label, so an id comparison would leave the chip naming the old project.
510
+ let boundDoc = null;
429
511
  // The non-default branch label (null on the default), read by `fitBranch`
430
512
  // so a resize re-evaluates the drop without a full re-render.
431
513
  let branchLabel = null;
@@ -447,8 +529,9 @@ function buildDocumentTitle(deps) {
447
529
  function activeBranchLabel() {
448
530
  return deps.session.document?._currentBranchName() ?? null;
449
531
  }
450
- async function refreshLocation() {
451
- const doc = deps.session.document;
532
+ // Resolves the label for one document, named by the caller so the in-flight
533
+ // fetch is attributed to the Document it was started for.
534
+ async function refreshLocation(doc) {
452
535
  const docId = doc?.id ?? null;
453
536
  if (!docId) {
454
537
  loc.hidden = true;
@@ -456,7 +539,7 @@ function buildDocumentTitle(deps) {
456
539
  }
457
540
  try {
458
541
  const record = await deps.getDocument(docId);
459
- if (deps.session.document?.id !== docId)
542
+ if (deps.session.document !== doc)
460
543
  return; // superseded
461
544
  // The server hands back an opaque location label (rendered verbatim);
462
545
  // the toolbar never composes it from project / org ids, so the
@@ -475,14 +558,14 @@ function buildDocumentTitle(deps) {
475
558
  loc.hidden = false;
476
559
  }
477
560
  catch {
478
- if (deps.session.document?.id === docId)
561
+ if (deps.session.document === doc)
479
562
  loc.hidden = true;
480
563
  }
481
564
  }
482
565
  function runManage() {
483
566
  void (async () => {
484
567
  await deps.session.run('manage');
485
- void refreshLocation();
568
+ void refreshLocation(deps.session.document);
486
569
  })();
487
570
  }
488
571
  title.onclick = runManage;
@@ -518,11 +601,10 @@ function buildDocumentTitle(deps) {
518
601
  branch.title = `On branch "${branchLabel}"`;
519
602
  }
520
603
  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();
604
+ // Re-resolve location only when the bound document changes.
605
+ if (doc !== boundDoc) {
606
+ boundDoc = doc;
607
+ void refreshLocation(doc);
526
608
  }
527
609
  };
528
610
  render();
@@ -565,7 +647,12 @@ function buildUndoRedo(deps) {
565
647
  function buildPresenceRoster(deps) {
566
648
  injectStyles(document);
567
649
  const root = el('div', 'brass-ui b-roster');
568
- let lastDocId = null;
650
+ // The document whose presence client is subscribed, held by INSTANCE.
651
+ // Presence is scoped to a (document, branch) pair, and a branch switch binds
652
+ // a new Document under the SAME id, so an id comparison would keep the
653
+ // roster subscribed to the branch the user just left and show the wrong
654
+ // people as present on the one they are looking at.
655
+ let boundDoc = null;
569
656
  let presenceOff = null;
570
657
  const renderRoster = (participants) => {
571
658
  root.replaceChildren(...participants.map((p) => {
@@ -607,9 +694,9 @@ function buildPresenceRoster(deps) {
607
694
  typeof off === 'function' ? off : () => presence.off('change', renderRoster);
608
695
  }
609
696
  const render = () => {
610
- const docId = deps.session.document?.id ?? null;
611
- if (docId !== lastDocId) {
612
- lastDocId = docId;
697
+ const doc = deps.session.document;
698
+ if (doc !== boundDoc) {
699
+ boundDoc = doc;
613
700
  bindPresence();
614
701
  }
615
702
  };
@@ -623,10 +710,10 @@ function buildPresenceRoster(deps) {
623
710
  },
624
711
  };
625
712
  }
626
- // A monochrome chain-link glyph (inherits `color` via `currentColor`). Built
713
+ // A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
627
714
  // in the SVG namespace because `createElement('svg')` yields an HTML element
628
715
  // that won't render.
629
- function chainLinkIcon() {
716
+ function strokeIcon(...paths) {
630
717
  const ns = 'http://www.w3.org/2000/svg';
631
718
  const svg = document.createElementNS(ns, 'svg');
632
719
  svg.setAttribute('width', '16');
@@ -638,20 +725,36 @@ function chainLinkIcon() {
638
725
  svg.setAttribute('stroke-linecap', 'round');
639
726
  svg.setAttribute('stroke-linejoin', 'round');
640
727
  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
- ]) {
728
+ for (const d of paths) {
645
729
  const path = document.createElementNS(ns, 'path');
646
730
  path.setAttribute('d', d);
647
731
  svg.append(path);
648
732
  }
649
733
  return svg;
650
734
  }
735
+ function chainLinkIcon() {
736
+ 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');
737
+ }
651
738
  // How long the popover holds its "Publishing..." affordance after a push
652
739
  // before giving up on the synced confirmation. Long enough to cover a normal
653
740
  // drive webhook round-trip.
654
741
  const PUBLISH_HOLD_MS = 30000;
742
+ // `value` when it is an absolute http(s) URL, else null. The drive-sync
743
+ // popover's "View in <drive>" link is the one place the toolbar puts a
744
+ // server-supplied string into an `href`, and an href runs whatever scheme it
745
+ // names: a `javascript:` URL executes in the app's own page on the click.
746
+ // A relative URL throws out of `new URL` and is refused with the rest, which
747
+ // is right here, since the link points at an external drive item.
748
+ function httpUrlOrNull(value) {
749
+ let url;
750
+ try {
751
+ url = new URL(value);
752
+ }
753
+ catch {
754
+ return null;
755
+ }
756
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
757
+ }
655
758
  // The drive-sync indicator: a link icon (dim at rest, accent / danger with a
656
759
  // dot when there is something to do) whose popover describes the state
657
760
  // (server-authored copy, verbatim) and offers publish / resolve. The receiver
@@ -679,8 +782,13 @@ function buildDriveSync(deps) {
679
782
  const dot = el('span', 'b-drive-dot');
680
783
  dot.setAttribute('aria-hidden', 'true');
681
784
  button.append(dot);
682
- let lastDocId = null;
785
+ // The document whose drive status is subscribed, held by INSTANCE. A branch
786
+ // switch binds a new Document under the same id, and each carries its own
787
+ // drive state, so an id comparison would leave the indicator describing the
788
+ // document that was closed.
789
+ let boundDoc = null;
683
790
  let statusOff = null;
791
+ let publishOff = null;
684
792
  // A publish runs on a server worker and reaches `synced` only after the
685
793
  // worker (and, on a file-roundtrip, the drive's importer) confirms. In that
686
794
  // gap the drive state still reads `ahead`, which would flash "unpublished
@@ -693,23 +801,105 @@ function buildDriveSync(deps) {
693
801
  let publishing = false;
694
802
  let publishError = false;
695
803
  let publishTimer = null;
804
+ // Set on a click, cleared by the next publish-state frame. Between the two,
805
+ // the held state describes the PREVIOUS attempt, and rendering it would
806
+ // report the last failure over the publish just started.
807
+ let publishStateStale = false;
696
808
  let currentStatus = null;
697
809
  const clearPublishing = () => {
698
810
  publishing = false;
811
+ // The stale mark is cleared with the hold it belongs to. It is otherwise
812
+ // cleared only by a publish-state frame, so a publish that draws none
813
+ // would leave the panel reading the target's resting sentence for the
814
+ // life of the document.
815
+ publishStateStale = false;
699
816
  if (publishTimer !== null) {
700
817
  clearTimeout(publishTimer);
701
818
  publishTimer = null;
702
819
  }
703
820
  };
821
+ // The panel for a document with no source item: the server's sentence about
822
+ // where the file lands, and the publish that puts it there. The first
823
+ // publish creates the artifact and makes the document its mirror, at which
824
+ // point the drive status arrives and the mirror panel above replaces this
825
+ // one.
826
+ const renderPublishOffer = (target) => {
827
+ root.hidden = false;
828
+ // A document the drive has never received is as far out of sync as one
829
+ // whose edits have not been published, so the icon carries the same
830
+ // attention an `ahead` document's does. What separates them is what the
831
+ // panel offers, not how much the reader is told to look.
832
+ root.classList.add('has-changes');
833
+ root.classList.remove('is-broken');
834
+ const state = publishStateStale ? null : (boundDoc?._publishState ?? null);
835
+ const inFlight = publishing || state?.publishing === true || target.publishing;
836
+ button.title = inFlight
837
+ ? 'Publishing...'
838
+ : state?.failed
839
+ ? `Publish to ${target.driveName} failed`
840
+ : `Publish to ${target.driveName}`;
841
+ const failed = !inFlight && (state?.failed === true || target.publishFailed === true);
842
+ const rows = [];
843
+ // One line, server-authored either way: the worker's phase copy while it
844
+ // runs, its account of the failure when it stopped, and the resting
845
+ // sentence otherwise. Marked as a problem when it is one, since nothing
846
+ // else in this panel distinguishes a document whose publish failed from
847
+ // one that has simply never published.
848
+ const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
849
+ p.textContent = inFlight
850
+ ? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
851
+ : (state?.detail ?? target.detail);
852
+ rows.push(p);
853
+ const pub = el('button');
854
+ pub.type = 'button';
855
+ pub.disabled = inFlight;
856
+ pub.textContent = inFlight
857
+ ? 'Publishing...'
858
+ : publishError || failed
859
+ ? `Publish to ${target.driveName}, retry`
860
+ : `Publish to ${target.driveName}`;
861
+ pub.onclick = () => {
862
+ const doc = deps.session.document;
863
+ if (!doc)
864
+ return;
865
+ publishError = false;
866
+ publishing = true;
867
+ publishStateStale = true;
868
+ // The same safety net the mirror panel keeps: a publish that neither
869
+ // errors nor lands leaves the panel offering the action again rather
870
+ // than held forever.
871
+ publishTimer = setTimeout(() => {
872
+ clearPublishing();
873
+ render(currentStatus);
874
+ }, PUBLISH_HOLD_MS);
875
+ render(currentStatus);
876
+ void doc.pushToDrive().catch(() => {
877
+ clearPublishing();
878
+ publishError = true;
879
+ render(currentStatus);
880
+ });
881
+ };
882
+ rows.push(pub);
883
+ pop.replaceChildren(...rows);
884
+ };
704
885
  const render = (status) => {
705
886
  currentStatus = status;
706
- // Hidden only when the doc is not drive-linked.
887
+ // A document that mirrors nothing can still have somewhere to go: its
888
+ // project may be linked to a drive that takes it as a new file, which is
889
+ // what an upload into a linked project sits waiting for. Offer that
890
+ // publish here, so an app gets it by mounting this piece rather than by
891
+ // building a surface of its own.
707
892
  if (status === null) {
708
- root.hidden = true;
709
- menu.close();
710
- clearPublishing();
711
- root.classList.remove('has-changes', 'is-broken');
712
- pop.replaceChildren();
893
+ const target = boundDoc?._publishTarget ?? null;
894
+ if (target === null) {
895
+ root.hidden = true;
896
+ menu.close();
897
+ clearPublishing();
898
+ root.classList.remove('has-changes', 'is-broken');
899
+ pop.replaceChildren();
900
+ return;
901
+ }
902
+ renderPublishOffer(target);
713
903
  return;
714
904
  }
715
905
  // The publish settles once the doc actually reaches synced; drop the
@@ -742,10 +932,11 @@ function buildDriveSync(deps) {
742
932
  // there is one), then the commands. Rebuilt on every status change, so an
743
933
  // open popover updates live.
744
934
  const rows = [];
745
- if (status.itemUrl) {
935
+ const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
936
+ if (itemUrl) {
746
937
  const head = el('div', 'b-drive-head');
747
938
  const link = el('a', 'b-drive-source');
748
- link.href = status.itemUrl;
939
+ link.href = itemUrl;
749
940
  link.target = '_blank';
750
941
  link.rel = 'noopener noreferrer';
751
942
  link.textContent = `View in ${driveName}`;
@@ -770,6 +961,14 @@ function buildDriveSync(deps) {
770
961
  p.textContent = description;
771
962
  rows.push(p);
772
963
  }
964
+ // The drive could not read what the last publish delivered. Shown in the
965
+ // drive's own words, above the commands: the publish succeeded, so
966
+ // nothing else in this panel says anything is wrong.
967
+ if (status.sourceUnreadable) {
968
+ const warn = el('p', 'b-drive-unreadable');
969
+ warn.textContent = status.sourceUnreadable;
970
+ rows.push(warn);
971
+ }
773
972
  if (isPublishing) {
774
973
  // Held while the publish is in flight (a local click hold, or the doc's
775
974
  // pending work-status), until it reaches synced (see `isPublishing`).
@@ -780,8 +979,9 @@ function buildDriveSync(deps) {
780
979
  rows.push(pub);
781
980
  }
782
981
  else if (status.canPush) {
783
- // `canPush` already encodes two-way + not broken + local edits, so this
784
- // never offers a no-op publish.
982
+ // `canPush` already encodes two-way + not broken + local edits + a
983
+ // server-writable format, so this never offers a publish the server
984
+ // would refuse.
785
985
  const pub = el('button');
786
986
  pub.type = 'button';
787
987
  pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
@@ -827,6 +1027,8 @@ function buildDriveSync(deps) {
827
1027
  function bind() {
828
1028
  statusOff?.();
829
1029
  statusOff = null;
1030
+ publishOff?.();
1031
+ publishOff = null;
830
1032
  const doc = deps.session.document;
831
1033
  if (!doc) {
832
1034
  render(null);
@@ -837,11 +1039,23 @@ function buildDriveSync(deps) {
837
1039
  // started from any surface (the dashboard card, the app) flips it and the
838
1040
  // indicator holds "Publishing..." without a local click.
839
1041
  statusOff = doc.onDriveStatus(render);
1042
+ // The same signal for a document that mirrors nothing yet, which has no
1043
+ // drive status to carry it: its first publish is the one that creates the
1044
+ // artifact, and this is how its progress and its failure arrive.
1045
+ publishOff = doc._onPublishState(() => {
1046
+ publishStateStale = false;
1047
+ // A publish the server has finished with ends the local click hold:
1048
+ // holding "Publishing..." for the rest of it would report a push that
1049
+ // is over as one still running.
1050
+ if (!doc._publishState.publishing)
1051
+ clearPublishing();
1052
+ render(currentStatus);
1053
+ });
840
1054
  }
841
1055
  const onSession = () => {
842
- const docId = deps.session.document?.id ?? null;
843
- if (docId !== lastDocId) {
844
- lastDocId = docId;
1056
+ const doc = deps.session.document;
1057
+ if (doc !== boundDoc) {
1058
+ boundDoc = doc;
845
1059
  bind();
846
1060
  }
847
1061
  };
@@ -852,6 +1066,7 @@ function buildDriveSync(deps) {
852
1066
  destroy() {
853
1067
  unsub();
854
1068
  statusOff?.();
1069
+ publishOff?.();
855
1070
  clearPublishing();
856
1071
  menu.destroy();
857
1072
  },
@@ -865,18 +1080,31 @@ function buildAccountMenu(deps) {
865
1080
  label: 'Account',
866
1081
  align: 'end',
867
1082
  });
1083
+ // The menu opens on the identity it acts for: the avatar at reading size,
1084
+ // the display name, and the email under it. The name line drops out when the
1085
+ // user has none, leaving the email as the single identifying line.
1086
+ const head = el('div', 'b-account-head');
1087
+ const headFace = buildAvatarFace();
1088
+ const faceEl = el('span', 'b-account-face');
1089
+ faceEl.append(...headFace.nodes);
1090
+ const nameEl = el('div', 'b-account-name');
868
1091
  const emailEl = el('div', 'b-email');
1092
+ const identity = el('div', 'b-account-id');
1093
+ identity.append(nameEl, emailEl);
1094
+ head.append(faceEl, identity);
1095
+ const headSep = el('hr', 'b-sep');
1096
+ headSep.setAttribute('role', 'separator');
869
1097
  // Opens the dashboard in a new tab, so the user keeps the app open in the
870
1098
  // current one. `/home` is the authenticated landing (resolves the active
871
1099
  // org); the bare origin is the sign-in page.
872
1100
  const dashboardLink = el('a');
873
- dashboardLink.textContent = 'Open dashboard';
1101
+ 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
1102
  dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
875
1103
  dashboardLink.target = '_blank';
876
1104
  dashboardLink.rel = 'noopener noreferrer';
877
1105
  dashboardLink.onclick = () => menu.close();
878
1106
  const signOutBtn = el('button');
879
- signOutBtn.textContent = 'Sign out';
1107
+ 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
1108
  signOutBtn.onclick = () => {
881
1109
  menu.close();
882
1110
  deps.signOut();
@@ -886,49 +1114,32 @@ function buildAccountMenu(deps) {
886
1114
  // meaningless, so the menu offers a single "Sign in" that takes the top
887
1115
  // window through the app's normal sign-in and returns to this URL.
888
1116
  const signInBtn = el('button');
889
- signInBtn.textContent = 'Sign in';
1117
+ 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
1118
  signInBtn.onclick = () => {
891
1119
  menu.close();
892
1120
  deps.signIn();
893
1121
  };
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
- });
1122
+ menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
1123
+ const triggerFace = buildAvatarFace();
1124
+ menu.trigger.append(...triggerFace.nodes);
911
1125
  const renderUser = (user) => {
912
- initialEl.textContent = user ? initialOf(user) : '?';
913
- emailEl.textContent = user ? (user.email ?? user.display_name ?? user.sub) : '';
1126
+ triggerFace.render(user);
1127
+ headFace.render(user);
1128
+ nameEl.textContent = user?.display_name ?? '';
1129
+ nameEl.hidden = !user?.display_name;
1130
+ emailEl.textContent = user ? (user.email ?? user.sub) : '';
1131
+ // The email ellipsizes to hold the menu's width, so the untruncated
1132
+ // address stays reachable on hover.
1133
+ emailEl.title = emailEl.textContent;
914
1134
  // Swap the menu body between the signed-in actions and the guest's lone
915
1135
  // "Sign in": a null user is an anonymous link holder.
916
1136
  const signedIn = user !== null;
1137
+ head.hidden = !signedIn;
1138
+ headSep.hidden = !signedIn;
917
1139
  emailEl.hidden = !signedIn;
918
1140
  dashboardLink.hidden = !signedIn;
919
1141
  signOutBtn.hidden = !signedIn;
920
1142
  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
1143
  };
933
1144
  renderUser(deps.getUser());
934
1145
  const unsub = deps.onUserChange(renderUser);
@@ -962,6 +1173,14 @@ function buildVersionPreview(deps) {
962
1173
  // closes it, when the active document changes, or when no doc is open.
963
1174
  let browsing = false;
964
1175
  let lastDocId = null;
1176
+ // The version the reader dismissed the bar at, when the document was left
1177
+ // holding one. A document that cannot return to its head (one pinned to a
1178
+ // version) stays on it, so without this the next render reads a non-null
1179
+ // `doc.version` and latches the bar the reader just dismissed straight back
1180
+ // on. A dismissal at the live head records nothing: there is no version
1181
+ // left to latch on, and the head is where the next browse-in-the-app
1182
+ // request arrives, so a record there would swallow it.
1183
+ let dismissedAt = undefined;
965
1184
  const session = deps.session;
966
1185
  const hideBar = () => {
967
1186
  root.classList.remove('b-version-on');
@@ -970,28 +1189,45 @@ function buildVersionPreview(deps) {
970
1189
  frame = null;
971
1190
  }
972
1191
  };
1192
+ // A failed intent reaches the reader through the bar (which reverts to the
1193
+ // document's real state) and the developer through `Brass.onError`. Both
1194
+ // halves matter: the bar moves optimistically, so a swallowed failure is a
1195
+ // bar naming a version nothing is showing.
1196
+ const attempt = async (doc, act) => {
1197
+ try {
1198
+ await act();
1199
+ }
1200
+ catch (err) {
1201
+ doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
1202
+ throw err;
1203
+ }
1204
+ };
973
1205
  const frameDeps = {
974
1206
  dashboardBaseUrl: deps.dashboardBaseUrl,
975
1207
  ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
976
- ...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
977
1208
  // Read the live active document at call time: a bridge intent lands
978
1209
  // async, so it must not close over a doc that may have been superseded.
979
- checkoutVersion: (version) => {
980
- void session.document?._checkoutVersion(version).catch(() => { });
1210
+ currentVersion: () => session.document?.version ?? null,
1211
+ checkoutVersion: async (version) => {
1212
+ const doc = session.document;
1213
+ if (!doc)
1214
+ return;
1215
+ await attempt(doc, () => doc._checkoutVersion(version));
981
1216
  },
982
- returnToHead: () => {
1217
+ returnToHead: async () => {
983
1218
  // Return to the live head but keep the bar open (the browsing session
984
1219
  // persists); the bar re-renders in its "at latest" state with a Close
985
1220
  // 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
- }
1221
+ const doc = session.document;
1222
+ if (!doc)
1223
+ return;
1224
+ await attempt(doc, () => doc.returnToHead());
992
1225
  },
993
1226
  fork: (fromVersion, name) => {
994
- void session.document?._forkFromVersion(fromVersion, name).catch(() => { });
1227
+ const doc = session.document;
1228
+ if (!doc)
1229
+ return;
1230
+ void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => { });
995
1231
  },
996
1232
  browse: () => {
997
1233
  void session.run('history');
@@ -1000,27 +1236,63 @@ function buildVersionPreview(deps) {
1000
1236
  // Explicit dismissal: end the browsing session and hide the bar
1001
1237
  // synchronously so the tap is instant. The document's own browsing
1002
1238
  // flag is cleared too, or the next render would latch the bar back on.
1239
+ const doc = session.document;
1003
1240
  browsing = false;
1004
- session.document?._setVersionBrowsing(false);
1241
+ dismissedAt = doc?.version ?? undefined;
1242
+ doc?._setVersionBrowsing(false);
1243
+ // Leaving the bar means leaving the preview: the bar is the only
1244
+ // surface that returns a document to its head, so a dismissal that
1245
+ // left it checked out would leave an app read-only with nothing to
1246
+ // clear it. A document pinned to its version has no head to return to
1247
+ // and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
1248
+ // Once the return lands, the dismissal has nothing left to hold
1249
+ // against: the doc is live, and a LATER pick of the same version in
1250
+ // the history dialog is a fresh preview that must latch the bar (its
1251
+ // pick sets only `version`, so a stale `dismissedAt` equal to it would
1252
+ // otherwise leave the reader checked out, read-only, with no bar and
1253
+ // no exit).
1254
+ if (doc && doc.version !== null) {
1255
+ void attempt(doc, () => doc.returnToHead())
1256
+ .then(() => {
1257
+ if (session.document === doc)
1258
+ dismissedAt = undefined;
1259
+ })
1260
+ .catch(() => { });
1261
+ }
1005
1262
  hideBar();
1006
1263
  },
1007
1264
  };
1008
1265
  const render = () => {
1009
1266
  const doc = session.document;
1010
1267
  const docId = doc?.id ?? null;
1011
- // A new active document (or none) ends any prior browsing session.
1268
+ // A new active document (or none) ends any prior browsing session. Keyed
1269
+ // on the id rather than the Document instance because a checkout keeps the
1270
+ // instance and only moves its `version`, so the bar follows the document
1271
+ // the user is stepping through rather than closing under them.
1012
1272
  if (docId !== lastDocId) {
1013
1273
  lastDocId = docId;
1014
1274
  browsing = false;
1275
+ dismissedAt = undefined;
1015
1276
  }
1016
1277
  // A live checkout (`doc.version !== null`) starts / keeps the browsing
1017
1278
  // session. Other read-only states (a view-only share link, a still-loading
1018
1279
  // doc) are not this bar's concern, and `doc.id` is null only on the offline
1019
1280
  // / test path the embed can't serve.
1281
+ // `versionBrowsing` is set by the reader asking for this surface, and the
1282
+ // close cleared it, so it names a fresh request whatever version the
1283
+ // document is on and outranks the dismissal.
1020
1284
  if (doc != null &&
1021
1285
  docId !== null &&
1022
- (doc.version !== null || doc.versionBrowsing)) {
1286
+ (doc.version !== null || doc.versionBrowsing) &&
1287
+ (doc.versionBrowsing || doc.version !== dismissedAt)) {
1023
1288
  browsing = true;
1289
+ dismissedAt = undefined;
1290
+ // Mirror the open bar onto the document, which is what makes it
1291
+ // read-only for as long as the bar is up. A preview entered by picking
1292
+ // a version sets only `version`, so without this the reader who then
1293
+ // returns to latest keeps the bar and gets the document back, which is
1294
+ // the pair the flag exists to keep together.
1295
+ doc._setVersionBrowsing(true);
1024
1296
  }
1025
1297
  // The bar stays open for the whole session, including at the live head
1026
1298
  // (`doc.version === null`, browsing still true), until `close`.
@@ -1033,6 +1305,7 @@ function buildVersionPreview(deps) {
1033
1305
  branch: doc.branchId ?? undefined,
1034
1306
  version: doc.version,
1035
1307
  branchName: doc._currentBranchName() ?? undefined,
1308
+ pinned: doc._isVersionPinned,
1036
1309
  });
1037
1310
  }
1038
1311
  else {