@brass-build/client 0.2.0 → 0.4.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 (168) hide show
  1. package/AGENTS.md +26 -5
  2. package/CHANGELOG.md +61 -2
  3. package/README.md +247 -87
  4. package/dist/acting-organization.d.ts +131 -0
  5. package/dist/acting-organization.d.ts.map +1 -0
  6. package/dist/acting-organization.js +278 -0
  7. package/dist/acting-organization.js.map +1 -0
  8. package/dist/api-headers.js +7 -7
  9. package/dist/api-headers.js.map +1 -1
  10. package/dist/call-service.d.ts +74 -0
  11. package/dist/call-service.d.ts.map +1 -0
  12. package/dist/call-service.js +253 -0
  13. package/dist/call-service.js.map +1 -0
  14. package/dist/client.d.ts +29 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +583 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-checkpoints.d.ts +1 -0
  19. package/dist/document-checkpoints.d.ts.map +1 -1
  20. package/dist/document-checkpoints.js +27 -0
  21. package/dist/document-checkpoints.js.map +1 -1
  22. package/dist/document-contents.d.ts +15 -0
  23. package/dist/document-contents.d.ts.map +1 -0
  24. package/dist/document-contents.js +88 -0
  25. package/dist/document-contents.js.map +1 -0
  26. package/dist/document-signals.d.ts +31 -1
  27. package/dist/document-signals.d.ts.map +1 -1
  28. package/dist/document-signals.js +59 -0
  29. package/dist/document-signals.js.map +1 -1
  30. package/dist/document-streams.d.ts +4 -2
  31. package/dist/document-streams.d.ts.map +1 -1
  32. package/dist/document-streams.js +6 -0
  33. package/dist/document-streams.js.map +1 -1
  34. package/dist/document-thumbnail-capture.d.ts +20 -0
  35. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  36. package/dist/document-thumbnail-capture.js +247 -9
  37. package/dist/document-thumbnail-capture.js.map +1 -1
  38. package/dist/documents-namespace.d.ts.map +1 -1
  39. package/dist/documents-namespace.js +3 -0
  40. package/dist/documents-namespace.js.map +1 -1
  41. package/dist/documents.d.ts +51 -3
  42. package/dist/documents.d.ts.map +1 -1
  43. package/dist/documents.js +415 -36
  44. package/dist/documents.js.map +1 -1
  45. package/dist/export-guest.d.ts.map +1 -1
  46. package/dist/export-guest.js +5 -0
  47. package/dist/export-guest.js.map +1 -1
  48. package/dist/handle-types.d.ts +23 -0
  49. package/dist/handle-types.d.ts.map +1 -1
  50. package/dist/handles.d.ts +1 -1
  51. package/dist/handles.d.ts.map +1 -1
  52. package/dist/handles.js +3 -3
  53. package/dist/handles.js.map +1 -1
  54. package/dist/hosted-ui.d.ts +10 -0
  55. package/dist/hosted-ui.d.ts.map +1 -1
  56. package/dist/hosted-ui.js +115 -30
  57. package/dist/hosted-ui.js.map +1 -1
  58. package/dist/importer.d.ts.map +1 -1
  59. package/dist/importer.js +28 -66
  60. package/dist/importer.js.map +1 -1
  61. package/dist/index.d.ts +8 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +25 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/internal.d.ts +2 -0
  66. package/dist/internal.d.ts.map +1 -1
  67. package/dist/internal.js +2 -0
  68. package/dist/internal.js.map +1 -1
  69. package/dist/mutate-batching.js +2 -2
  70. package/dist/mutate-batching.js.map +1 -1
  71. package/dist/open-document.d.ts +16 -1
  72. package/dist/open-document.d.ts.map +1 -1
  73. package/dist/open-document.js +123 -35
  74. package/dist/open-document.js.map +1 -1
  75. package/dist/presence.d.ts +10 -0
  76. package/dist/presence.d.ts.map +1 -1
  77. package/dist/presence.js.map +1 -1
  78. package/dist/record-paging.d.ts +13 -14
  79. package/dist/record-paging.d.ts.map +1 -1
  80. package/dist/record-paging.js +13 -14
  81. package/dist/record-paging.js.map +1 -1
  82. package/dist/serializer.d.ts +3 -3
  83. package/dist/session-toolbar.d.ts +9 -0
  84. package/dist/session-toolbar.d.ts.map +1 -1
  85. package/dist/session-toolbar.js +191 -21
  86. package/dist/session-toolbar.js.map +1 -1
  87. package/dist/tail-ops.js +2 -2
  88. package/dist/tail-ops.js.map +1 -1
  89. package/dist/types.d.ts +0 -42
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/undo.d.ts +1 -1
  92. package/dist/undo.d.ts.map +1 -1
  93. package/dist/undo.js +1 -1
  94. package/dist/undo.js.map +1 -1
  95. package/dist/version-bar.d.ts.map +1 -1
  96. package/dist/version-bar.js +75 -4
  97. package/dist/version-bar.js.map +1 -1
  98. package/dist/version.d.ts +2 -2
  99. package/dist/version.js +1 -1
  100. package/dist/wasm-ready-gate.d.ts +13 -0
  101. package/dist/wasm-ready-gate.d.ts.map +1 -0
  102. package/dist/wasm-ready-gate.js +28 -0
  103. package/dist/wasm-ready-gate.js.map +1 -0
  104. package/dist/wasm-runtime.browser.d.ts +11 -0
  105. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  106. package/dist/wasm-runtime.browser.js +44 -0
  107. package/dist/wasm-runtime.browser.js.map +1 -0
  108. package/dist/wasm-runtime.d.ts +6 -0
  109. package/dist/wasm-runtime.d.ts.map +1 -0
  110. package/dist/wasm-runtime.js +20 -0
  111. package/dist/wasm-runtime.js.map +1 -0
  112. package/dist/when-saved.d.ts +2 -0
  113. package/dist/when-saved.d.ts.map +1 -0
  114. package/dist/when-saved.js +70 -0
  115. package/dist/when-saved.js.map +1 -0
  116. package/dist/ws-client.d.ts +4 -0
  117. package/dist/ws-client.d.ts.map +1 -1
  118. package/dist/ws-client.js +70 -1
  119. package/dist/ws-client.js.map +1 -1
  120. package/dist/ws-frame-limit.d.ts.map +1 -1
  121. package/dist/ws-frame-limit.js +9 -10
  122. package/dist/ws-frame-limit.js.map +1 -1
  123. package/docs/app-manifest.md +335 -5
  124. package/docs/custom-chrome.md +7 -2
  125. package/docs/presence.md +12 -0
  126. package/docs/services.md +265 -0
  127. package/docs/text-fields.md +191 -0
  128. package/package.json +4 -5
  129. package/src/acting-organization.ts +396 -0
  130. package/src/api-headers.ts +7 -7
  131. package/src/call-service.ts +354 -0
  132. package/src/client.ts +655 -15
  133. package/src/document-checkpoints.ts +30 -0
  134. package/src/document-contents.ts +104 -0
  135. package/src/document-signals.ts +113 -1
  136. package/src/document-streams.ts +15 -1
  137. package/src/document-thumbnail-capture.ts +253 -9
  138. package/src/documents-namespace.ts +20 -10
  139. package/src/documents.ts +460 -33
  140. package/src/export-guest.ts +5 -0
  141. package/src/handle-types.ts +35 -0
  142. package/src/handles.ts +7 -6
  143. package/src/hosted-ui.ts +155 -29
  144. package/src/importer.ts +28 -62
  145. package/src/index.ts +43 -4
  146. package/src/internal.ts +10 -0
  147. package/src/mutate-batching.ts +2 -2
  148. package/src/open-document.ts +179 -39
  149. package/src/presence.ts +6 -0
  150. package/src/record-paging.ts +13 -14
  151. package/src/serializer.ts +3 -3
  152. package/src/session-toolbar.ts +219 -22
  153. package/src/tail-ops.ts +2 -2
  154. package/src/types.ts +6 -4
  155. package/src/undo.ts +1 -1
  156. package/src/version-bar.ts +77 -7
  157. package/src/version.ts +1 -1
  158. package/src/wasm-ready-gate.ts +30 -0
  159. package/src/wasm-runtime.browser.ts +48 -0
  160. package/src/wasm-runtime.ts +21 -0
  161. package/src/when-saved.ts +66 -0
  162. package/src/ws-client.ts +70 -1
  163. package/src/ws-frame-limit.ts +9 -10
  164. package/dist/vite.d.ts +0 -31
  165. package/dist/vite.d.ts.map +0 -1
  166. package/dist/vite.js +0 -161
  167. package/dist/vite.js.map +0 -1
  168. package/src/vite.ts +0 -214
@@ -19,7 +19,10 @@ import type { BrassUser } from './types.js';
19
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,
@@ -70,6 +73,17 @@ export interface AccountMenuDeps {
70
73
  // dashboard" link. The client's resolved dashboard origin (picks the
71
74
  // environment), with no trailing slash.
72
75
  dashboardBaseUrl: string;
76
+ // The organization this app is currently acting in, or null while none has
77
+ // been resolved or the one resolved has no name yet.
78
+ getActingOrganization?: () => { id: string; name: string } | null;
79
+ // Push-based, because the value is resolved lazily at the first act that
80
+ // needs it and can change when the reader switches.
81
+ subscribeActingOrganization?: (
82
+ listener: (organization: { id: string; name: string } | null) => void,
83
+ ) => () => void;
84
+ // Opens the hosted surface that picks the organization to act in, which is
85
+ // also where one gets created.
86
+ switchOrganization?: () => void;
73
87
  }
74
88
 
75
89
  export type SessionToolbarDeps = FileMenuDeps &
@@ -197,7 +211,8 @@ const CSS = `
197
211
  margin:4px 2px; }
198
212
  /* [hidden] must beat the block rule above (the account menu swaps its body
199
213
  between signed-in actions and the guest's "Sign in"). */
200
- .b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden] { display:none; }
214
+ .b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden],
215
+ .b-pop .b-account-org[hidden] { display:none; }
201
216
  .b-identity { display:flex; flex-direction:column; align-items:flex-start;
202
217
  gap:1px; min-width:0; }
203
218
  /* Cancels the horizontal padding the title and location carry for their focus
@@ -264,9 +279,12 @@ const CSS = `
264
279
  /* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
265
280
  menu: a grid item's default min-width is its content's intrinsic size. */
266
281
  .b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
267
- .b-account-name, .b-email { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
282
+ .b-account-name, .b-email, .b-account-org {
283
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
268
284
  .b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
269
- .b-email { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
285
+ /* The organization line qualifies the identity rather than naming it, so it
286
+ sits at the address's quiet tier and never competes with the name above. */
287
+ .b-email, .b-account-org { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
270
288
  .b-account .b-pop button, .b-account .b-pop a {
271
289
  display:flex; align-items:center; gap:10px; font-size:13px; }
272
290
  .b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
@@ -309,6 +327,10 @@ const CSS = `
309
327
  .b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
310
328
  .b-drive-source:hover { text-decoration:underline; }
311
329
  .b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
330
+ /* The drive rejecting a published file is the one line in this panel that
331
+ reports a problem, so it carries the danger tone rather than the muted one
332
+ every other line uses. */
333
+ .b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
312
334
  /* Historical-version preview bar: a full-width strip the toolbar shows below
313
335
  the bar whenever the active doc is previewing an earlier version. The strip
314
336
  itself is the platform-served version-bar iframe (the SDK owns only this
@@ -958,6 +980,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
958
980
  // document that was closed.
959
981
  let boundDoc: DocumentSession['document'] = null;
960
982
  let statusOff: (() => void) | null = null;
983
+ let publishOff: (() => void) | null = null;
961
984
 
962
985
  // A publish runs on a server worker and reaches `synced` only after the
963
986
  // worker (and, on a file-roundtrip, the drive's importer) confirms. In that
@@ -971,24 +994,111 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
971
994
  let publishing = false;
972
995
  let publishError = false;
973
996
  let publishTimer: ReturnType<typeof setTimeout> | null = null;
997
+ // Set on a click, cleared by the next publish-state frame. Between the two,
998
+ // the held state describes the PREVIOUS attempt, and rendering it would
999
+ // report the last failure over the publish just started.
1000
+ let publishStateStale = false;
974
1001
  let currentStatus: DocumentDriveStatus | null = null;
975
1002
  const clearPublishing = (): void => {
976
1003
  publishing = false;
1004
+ // The stale mark is cleared with the hold it belongs to. It is otherwise
1005
+ // cleared only by a publish-state frame, so a publish that draws none
1006
+ // would leave the panel reading the target's resting sentence for the
1007
+ // life of the document.
1008
+ publishStateStale = false;
977
1009
  if (publishTimer !== null) {
978
1010
  clearTimeout(publishTimer);
979
1011
  publishTimer = null;
980
1012
  }
981
1013
  };
982
1014
 
1015
+ // The panel for a document with no source item: the server's sentence about
1016
+ // where the file lands, and the publish that puts it there. The first
1017
+ // publish creates the artifact and makes the document its mirror, at which
1018
+ // point the drive status arrives and the mirror panel above replaces this
1019
+ // one.
1020
+ const renderPublishOffer = (target: DrivePublishTarget): void => {
1021
+ root.hidden = false;
1022
+ // A document the drive has never received is as far out of sync as one
1023
+ // whose edits have not been published, so the icon carries the same
1024
+ // attention an `ahead` document's does. What separates them is what the
1025
+ // panel offers, not how much the reader is told to look.
1026
+ root.classList.add('has-changes');
1027
+ root.classList.remove('is-broken');
1028
+ const state = publishStateStale ? null : (boundDoc?._publishState ?? null);
1029
+ const inFlight =
1030
+ publishing || state?.publishing === true || target.publishing;
1031
+ button.title = inFlight
1032
+ ? 'Publishing...'
1033
+ : state?.failed
1034
+ ? `Publish to ${target.driveName} failed`
1035
+ : `Publish to ${target.driveName}`;
1036
+
1037
+ const failed =
1038
+ !inFlight && (state?.failed === true || target.publishFailed === true);
1039
+
1040
+ const rows: HTMLElement[] = [];
1041
+ // One line, server-authored either way: the worker's phase copy while it
1042
+ // runs, its account of the failure when it stopped, and the resting
1043
+ // sentence otherwise. Marked as a problem when it is one, since nothing
1044
+ // else in this panel distinguishes a document whose publish failed from
1045
+ // one that has simply never published.
1046
+ const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
1047
+ p.textContent = inFlight
1048
+ ? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
1049
+ : (state?.detail ?? target.detail);
1050
+ rows.push(p);
1051
+
1052
+ const pub = el('button');
1053
+ pub.type = 'button';
1054
+ pub.disabled = inFlight;
1055
+ pub.textContent = inFlight
1056
+ ? 'Publishing...'
1057
+ : publishError || failed
1058
+ ? `Publish to ${target.driveName}, retry`
1059
+ : `Publish to ${target.driveName}`;
1060
+ pub.onclick = () => {
1061
+ const doc = deps.session.document;
1062
+ if (!doc) return;
1063
+ publishError = false;
1064
+ publishing = true;
1065
+ publishStateStale = true;
1066
+ // The same safety net the mirror panel keeps: a publish that neither
1067
+ // errors nor lands leaves the panel offering the action again rather
1068
+ // than held forever.
1069
+ publishTimer = setTimeout(() => {
1070
+ clearPublishing();
1071
+ render(currentStatus);
1072
+ }, PUBLISH_HOLD_MS);
1073
+ render(currentStatus);
1074
+ void doc.pushToDrive().catch(() => {
1075
+ clearPublishing();
1076
+ publishError = true;
1077
+ render(currentStatus);
1078
+ });
1079
+ };
1080
+ rows.push(pub);
1081
+ pop.replaceChildren(...rows);
1082
+ };
1083
+
983
1084
  const render = (status: DocumentDriveStatus | null): void => {
984
1085
  currentStatus = status;
985
- // Hidden only when the doc is not drive-linked.
1086
+ // A document that mirrors nothing can still have somewhere to go: its
1087
+ // project may be linked to a drive that takes it as a new file, which is
1088
+ // what an upload into a linked project sits waiting for. Offer that
1089
+ // publish here, so an app gets it by mounting this piece rather than by
1090
+ // building a surface of its own.
986
1091
  if (status === null) {
987
- root.hidden = true;
988
- menu.close();
989
- clearPublishing();
990
- root.classList.remove('has-changes', 'is-broken');
991
- pop.replaceChildren();
1092
+ const target = boundDoc?._publishTarget ?? null;
1093
+ if (target === null) {
1094
+ root.hidden = true;
1095
+ menu.close();
1096
+ clearPublishing();
1097
+ root.classList.remove('has-changes', 'is-broken');
1098
+ pop.replaceChildren();
1099
+ return;
1100
+ }
1101
+ renderPublishOffer(target);
992
1102
  return;
993
1103
  }
994
1104
  // The publish settles once the doc actually reaches synced; drop the
@@ -1054,6 +1164,15 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1054
1164
  rows.push(p);
1055
1165
  }
1056
1166
 
1167
+ // The drive could not read what the last publish delivered. Shown in the
1168
+ // drive's own words, above the commands: the publish succeeded, so
1169
+ // nothing else in this panel says anything is wrong.
1170
+ if (status.sourceUnreadable) {
1171
+ const warn = el('p', 'b-drive-unreadable');
1172
+ warn.textContent = status.sourceUnreadable;
1173
+ rows.push(warn);
1174
+ }
1175
+
1057
1176
  if (isPublishing) {
1058
1177
  // Held while the publish is in flight (a local click hold, or the doc's
1059
1178
  // pending work-status), until it reaches synced (see `isPublishing`).
@@ -1063,8 +1182,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1063
1182
  pub.disabled = true;
1064
1183
  rows.push(pub);
1065
1184
  } else if (status.canPush) {
1066
- // `canPush` already encodes two-way + not broken + local edits, so this
1067
- // never offers a no-op publish.
1185
+ // `canPush` already encodes two-way + not broken + local edits + a
1186
+ // server-writable format, so this never offers a publish the server
1187
+ // would refuse.
1068
1188
  const pub = el('button');
1069
1189
  pub.type = 'button';
1070
1190
  pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
@@ -1112,6 +1232,8 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1112
1232
  function bind(): void {
1113
1233
  statusOff?.();
1114
1234
  statusOff = null;
1235
+ publishOff?.();
1236
+ publishOff = null;
1115
1237
  const doc = deps.session.document;
1116
1238
  if (!doc) {
1117
1239
  render(null);
@@ -1122,6 +1244,17 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1122
1244
  // started from any surface (the dashboard card, the app) flips it and the
1123
1245
  // indicator holds "Publishing..." without a local click.
1124
1246
  statusOff = doc.onDriveStatus(render);
1247
+ // The same signal for a document that mirrors nothing yet, which has no
1248
+ // drive status to carry it: its first publish is the one that creates the
1249
+ // artifact, and this is how its progress and its failure arrive.
1250
+ publishOff = doc._onPublishState(() => {
1251
+ publishStateStale = false;
1252
+ // A publish the server has finished with ends the local click hold:
1253
+ // holding "Publishing..." for the rest of it would report a push that
1254
+ // is over as one still running.
1255
+ if (!doc._publishState.publishing) clearPublishing();
1256
+ render(currentStatus);
1257
+ });
1125
1258
  }
1126
1259
 
1127
1260
  const onSession = (): void => {
@@ -1138,6 +1271,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
1138
1271
  destroy(): void {
1139
1272
  unsub();
1140
1273
  statusOff?.();
1274
+ publishOff?.();
1141
1275
  clearPublishing();
1142
1276
  menu.destroy();
1143
1277
  },
@@ -1153,22 +1287,25 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1153
1287
  align: 'end',
1154
1288
  });
1155
1289
  // The menu opens on the identity it acts for: the avatar at reading size,
1156
- // the display name, and the email under it. The name line drops out when the
1157
- // user has none, leaving the email as the single identifying line.
1290
+ // the display name, the email under it, and the organization the app acts in
1291
+ // under that. The name line drops out when the user has none, leaving the
1292
+ // email as the single identifying line.
1158
1293
  const head = el('div', 'b-account-head');
1159
1294
  const headFace = buildAvatarFace();
1160
1295
  const faceEl = el('span', 'b-account-face');
1161
1296
  faceEl.append(...headFace.nodes);
1162
1297
  const nameEl = el('div', 'b-account-name');
1163
1298
  const emailEl = el('div', 'b-email');
1299
+ const orgEl = el('div', 'b-account-org');
1164
1300
  const identity = el('div', 'b-account-id');
1165
- identity.append(nameEl, emailEl);
1301
+ identity.append(nameEl, emailEl, orgEl);
1166
1302
  head.append(faceEl, identity);
1167
1303
  const headSep = el('hr', 'b-sep');
1168
1304
  headSep.setAttribute('role', 'separator');
1169
1305
  // Opens the dashboard in a new tab, so the user keeps the app open in the
1170
- // current one. `/home` is the authenticated landing (resolves the active
1171
- // org); the bare origin is the sign-in page.
1306
+ // current one, and lands in the organization the app is working in rather
1307
+ // than wherever the dashboard was left. `/home` is the authenticated
1308
+ // landing and stands in when the app is working in none.
1172
1309
  const dashboardLink = el('a');
1173
1310
  dashboardLink.append(
1174
1311
  strokeIcon(
@@ -1182,6 +1319,23 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1182
1319
  dashboardLink.target = '_blank';
1183
1320
  dashboardLink.rel = 'noopener noreferrer';
1184
1321
  dashboardLink.onclick = () => menu.close();
1322
+ // Sits directly above the dashboard link, so the organization the header
1323
+ // names and the action that changes it read as one block.
1324
+ const switchOrgBtn = el('button');
1325
+ switchOrgBtn.type = 'button';
1326
+ switchOrgBtn.append(
1327
+ strokeIcon(
1328
+ 'm17 2 4 4-4 4',
1329
+ 'M3 11v-1a4 4 0 0 1 4-4h14',
1330
+ 'm7 22-4-4 4-4',
1331
+ 'M21 13v1a4 4 0 0 1-4 4H3',
1332
+ ),
1333
+ 'Switch organization...',
1334
+ );
1335
+ switchOrgBtn.onclick = () => {
1336
+ menu.close();
1337
+ deps.switchOrganization?.();
1338
+ };
1185
1339
  const signOutBtn = el('button');
1186
1340
  signOutBtn.append(
1187
1341
  strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'),
@@ -1204,12 +1358,18 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1204
1358
  menu.close();
1205
1359
  deps.signIn();
1206
1360
  };
1207
- menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
1361
+ menu.pop.append(head, headSep, switchOrgBtn, dashboardLink, signOutBtn, signInBtn);
1208
1362
 
1209
1363
  const triggerFace = buildAvatarFace();
1210
1364
  menu.trigger.append(...triggerFace.nodes);
1211
1365
 
1212
- const renderUser = (user: BrassUser | null): void => {
1366
+ // The menu acts for a (user, organization) pair, and each half arrives on
1367
+ // its own listener, so both are held here and one render draws them.
1368
+ let currentUser = deps.getUser();
1369
+ let organization = deps.getActingOrganization?.() ?? null;
1370
+
1371
+ const render = (): void => {
1372
+ const user = currentUser;
1213
1373
  triggerFace.render(user);
1214
1374
  headFace.render(user);
1215
1375
  nameEl.textContent = user?.display_name ?? '';
@@ -1227,13 +1387,40 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
1227
1387
  dashboardLink.hidden = !signedIn;
1228
1388
  signOutBtn.hidden = !signedIn;
1229
1389
  signInBtn.hidden = signedIn;
1390
+ // The organization line names what the app is acting in, so it is drawn
1391
+ // only when there is something to name: an app that has not needed an
1392
+ // organization yet has not resolved one, and resolving one to fill this
1393
+ // line is the boot-time question the platform refuses.
1394
+ const organizationName = signedIn ? (organization?.name ?? '') : '';
1395
+ orgEl.textContent = organizationName;
1396
+ orgEl.title = organizationName;
1397
+ orgEl.hidden = organizationName === '';
1398
+ dashboardLink.href =
1399
+ signedIn && organization
1400
+ ? `${deps.dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
1401
+ : `${deps.dashboardBaseUrl}/home`;
1402
+ // Offered to every signed-in reader, including one the line has nothing
1403
+ // to name and one who belongs to a single organization. The surface
1404
+ // behind it both moves the app and creates an organization, so gating it
1405
+ // on already having somewhere else to go would withhold it from exactly
1406
+ // the person it is for; and an app reached by its own URL has resolved
1407
+ // nothing to name, which is the state a deliberate choice settles.
1408
+ switchOrgBtn.hidden = !signedIn || deps.switchOrganization === undefined;
1230
1409
  };
1231
- renderUser(deps.getUser());
1232
- const unsub = deps.onUserChange(renderUser);
1410
+ render();
1411
+ const unsub = deps.onUserChange((user) => {
1412
+ currentUser = user;
1413
+ render();
1414
+ });
1415
+ const unsubOrganization = deps.subscribeActingOrganization?.((next) => {
1416
+ organization = next;
1417
+ render();
1418
+ });
1233
1419
  return {
1234
1420
  root: menu.root,
1235
1421
  destroy(): void {
1236
1422
  unsub();
1423
+ unsubOrganization?.();
1237
1424
  menu.destroy();
1238
1425
  },
1239
1426
  };
@@ -1334,8 +1521,18 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
1334
1521
  // left it checked out would leave an app read-only with nothing to
1335
1522
  // clear it. A document pinned to its version has no head to return to
1336
1523
  // and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
1524
+ // Once the return lands, the dismissal has nothing left to hold
1525
+ // against: the doc is live, and a LATER pick of the same version in
1526
+ // the history dialog is a fresh preview that must latch the bar (its
1527
+ // pick sets only `version`, so a stale `dismissedAt` equal to it would
1528
+ // otherwise leave the reader checked out, read-only, with no bar and
1529
+ // no exit).
1337
1530
  if (doc && doc.version !== null) {
1338
- void attempt(doc, () => doc.returnToHead()).catch(() => {});
1531
+ void attempt(doc, () => doc.returnToHead())
1532
+ .then(() => {
1533
+ if (session.document === doc) dismissedAt = undefined;
1534
+ })
1535
+ .catch(() => {});
1339
1536
  }
1340
1537
  hideBar();
1341
1538
  },
package/src/tail-ops.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Tail ops arrive in one of two shapes and are used in only one.
2
2
  //
3
- // An op that fit its durable row carries its bytes inline. One that
4
- // spilled to storage carries a short-lived signed URL instead, because a
3
+ // An op small enough to store inline carries its bytes. A larger one
4
+ // carries a short-lived signed URL instead, because a
5
5
  // join response holding several megabytes of base64 is a response the
6
6
  // server cannot build. Both mean the same thing to a reader: bytes to
7
7
  // apply, in seq order.
package/src/types.ts CHANGED
@@ -87,10 +87,12 @@ export interface UndoControllerOptions {
87
87
  * A document's own view of how its sync is going, as the client sees it.
88
88
  *
89
89
  * The server records what reached it, so the facts that separate a write
90
- * that was lost from a client that is merely slow live only here. Read it
91
- * when a save is taking longer than it should; `Brass.onError` reports
92
- * failures, and this reports the state a failure has not yet been declared
93
- * for.
90
+ * that was lost from a client that is merely slow live only here.
91
+ *
92
+ * @internal Read on demand by Brass's own surfaces, through the `@internal`
93
+ * `Document.diagnostics()`. An app reads `savingCount` to know a save is
94
+ * outstanding and `Brass.onError` to hear a failure; the SDK resolves a
95
+ * stuck save itself.
94
96
  */
95
97
  export interface DocSyncDiagnostics {
96
98
  /** Ops committed locally that the server has not acknowledged. */
package/src/undo.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // local-peer-scoped (reverts only this peer's ops), group bracketing collapses
3
3
  // same-stroke edits into one entry, and remote-import bookkeeping keeps the
4
4
  // redo stack honest across other peers' edits.
5
- import { UndoManager, type LoroDoc } from 'loro-crdt';
5
+ import { UndoManager, type LoroDoc } from './wasm-runtime.js';
6
6
 
7
7
  import type { UndoControllerOptions } from './types.js';
8
8
 
@@ -88,6 +88,10 @@ function clampHeight(h: number): number {
88
88
  // takes the strip. Matches the hosted-UI launcher's own long window.
89
89
  const VERSION_BAR_READY_TIMEOUT_MS = 10_000;
90
90
 
91
+ // How long a frame that answered gets to draw its strip before the
92
+ // placeholder comes off anyway. See `armRevealTimer`.
93
+ const VERSION_BAR_REVEAL_TIMEOUT_MS = 10_000;
94
+
91
95
  export function createVersionBarFrame(
92
96
  root: HTMLElement,
93
97
  deps: VersionBarDeps,
@@ -164,6 +168,10 @@ export function createVersionBarFrame(
164
168
  // one that never answers, which is why this is a timer rather than a
165
169
  // handler.
166
170
  let readyTimer: ReturnType<typeof setTimeout> | undefined;
171
+ // The placeholder holds the strip's height until the embed has drawn its
172
+ // own, which `booted` reports and this timer backstops.
173
+ let revealed = false;
174
+ let revealTimer: ReturnType<typeof setTimeout> | undefined;
167
175
 
168
176
  // Hand the strip to the SDK-drawn fallback, which carries the two controls
169
177
  // that need no timeline (return to latest, close).
@@ -174,12 +182,37 @@ export function createVersionBarFrame(
174
182
  clearTimeout(readyTimer);
175
183
  readyTimer = undefined;
176
184
  }
185
+ if (revealTimer !== undefined) {
186
+ clearTimeout(revealTimer);
187
+ revealTimer = undefined;
188
+ }
177
189
  placeholder.style.display = 'none';
178
190
  iframe.style.height = '0';
179
191
  fallback.hidden = false;
180
192
  renderFallback();
181
193
  }
182
194
 
195
+ // Give the embed's strip the height the placeholder was holding.
196
+ function revealFrame(): void {
197
+ if (destroyed || failed || revealed) return;
198
+ revealed = true;
199
+ if (revealTimer !== undefined) {
200
+ clearTimeout(revealTimer);
201
+ revealTimer = undefined;
202
+ }
203
+ placeholder.style.display = 'none';
204
+ iframe.style.height = `${READY_HEIGHT}px`;
205
+ }
206
+
207
+ // A frame that answers and then draws nothing (a boot that dies past its
208
+ // first script, an embed page older than `booted`) holds the placeholder
209
+ // otherwise. The swap is what the reader's controls arrive with, so take
210
+ // it on the timer rather than leaving the strip inert.
211
+ function armRevealTimer(): void {
212
+ if (revealed || revealTimer !== undefined) return;
213
+ revealTimer = setTimeout(revealFrame, VERSION_BAR_REVEAL_TIMEOUT_MS);
214
+ }
215
+
183
216
  function armReadyTimer(): void {
184
217
  if (readyTimer !== undefined) clearTimeout(readyTimer);
185
218
  readyTimer = setTimeout(() => {
@@ -239,11 +272,25 @@ export function createVersionBarFrame(
239
272
  );
240
273
  }
241
274
 
275
+ // Intents still awaiting their answer. While one is in flight, `update()`
276
+ // stays quiet: the checkout it runs fires the host's subscribers before its
277
+ // promise resolves, so a render lands here ahead of `confirmIntent`, and an
278
+ // uncorrelated `version-changed` posted then reaches the embed as a phantom
279
+ // external move one message before the answer to its own step. The answer
280
+ // reads the live version at settle time, so nothing the quiet window saw is
281
+ // lost.
282
+ let intentsInFlight = 0;
283
+
242
284
  function runIntent(requestId: unknown, intent: () => Promise<void>): void {
243
- void intent().then(
244
- () => confirmIntent(requestId, true),
245
- () => confirmIntent(requestId, false),
246
- );
285
+ intentsInFlight += 1;
286
+ const settle = (ok: boolean) => (): void => {
287
+ // Decremented in every outcome. A count this leaves above zero silences
288
+ // `update()` for the life of the bar, so an external version change
289
+ // would never reach the embed again.
290
+ intentsInFlight -= 1;
291
+ confirmIntent(requestId, ok);
292
+ };
293
+ void intent().then(settle(true), settle(false));
247
294
  }
248
295
 
249
296
  function onMessage(event: MessageEvent): void {
@@ -253,16 +300,23 @@ export function createVersionBarFrame(
253
300
  if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
254
301
  switch (data['type']) {
255
302
  case 'ready': {
303
+ // The frame is alive. It says nothing about the embed having drawn
304
+ // its strip yet, so the placeholder stays until `booted`: the embed
305
+ // posts this from a script that runs before its application, and
306
+ // swapping here would trade the themed placeholder for an empty one.
256
307
  ready = true;
257
308
  if (readyTimer !== undefined) {
258
309
  clearTimeout(readyTimer);
259
310
  readyTimer = undefined;
260
311
  }
261
- placeholder.style.display = 'none';
262
- iframe.style.height = `${READY_HEIGHT}px`;
312
+ armRevealTimer();
263
313
  postInit();
264
314
  break;
265
315
  }
316
+ case 'booted': {
317
+ revealFrame();
318
+ break;
319
+ }
266
320
  case 'resize': {
267
321
  const h = data['height'];
268
322
  if (typeof h === 'number') iframe.style.height = `${clampHeight(h)}px`;
@@ -322,6 +376,13 @@ export function createVersionBarFrame(
322
376
  // page gets a fresh verdict, so the failed state resets with it.
323
377
  ready = false;
324
378
  failed = false;
379
+ // The placeholder is back up, so the swap the new page's `booted`
380
+ // asks for is one this bar has not made yet.
381
+ revealed = false;
382
+ if (revealTimer !== undefined) {
383
+ clearTimeout(revealTimer);
384
+ revealTimer = undefined;
385
+ }
325
386
  deliveredVersion = undefined;
326
387
  fallback.hidden = true;
327
388
  placeholder.style.display = '';
@@ -336,7 +397,9 @@ export function createVersionBarFrame(
336
397
  }
337
398
  // Only an actual version change reaches the embed (dedup), so a
338
399
  // re-render that didn't move the version can't clobber the bar's state.
339
- if (ready && next.version !== deliveredVersion) {
400
+ // A change while an intent is in flight is that intent's own doing and
401
+ // is delivered by its answer instead.
402
+ if (ready && intentsInFlight === 0 && next.version !== deliveredVersion) {
340
403
  deliveredVersion = next.version;
341
404
  iframe.contentWindow?.postMessage(
342
405
  { [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'version-changed', version: next.version },
@@ -346,10 +409,17 @@ export function createVersionBarFrame(
346
409
  },
347
410
  destroy(): void {
348
411
  destroyed = true;
412
+ // A bar torn down mid-intent leaves the count standing; a rebuilt bar
413
+ // gets a fresh closure, but this keeps the teardown total.
414
+ intentsInFlight = 0;
349
415
  if (readyTimer !== undefined) {
350
416
  clearTimeout(readyTimer);
351
417
  readyTimer = undefined;
352
418
  }
419
+ if (revealTimer !== undefined) {
420
+ clearTimeout(revealTimer);
421
+ revealTimer = undefined;
422
+ }
353
423
  window.removeEventListener('message', onMessage);
354
424
  iframe.remove();
355
425
  placeholder.remove();
package/src/version.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  // can measure the version distribution of deployed apps and time
4
4
  // compatibility windows against it. A unit test pins this to
5
5
  // package.json's version; the release bump touches both together.
6
- export const SDK_VERSION = '0.2.0';
6
+ export const SDK_VERSION = '0.4.0';
7
7
 
8
8
  export const SDK_CLIENT_ID = `sdk/${SDK_VERSION}`;
@@ -0,0 +1,30 @@
1
+ // The once-only gate the browser runtime half readies its WASM behind, kept
2
+ // separate from it so it can be exercised without a real engine: the half
3
+ // itself names a WASM-shipping dependency, which pins it to an environment
4
+ // that can load one.
5
+
6
+ /**
7
+ * Build a function that runs `inits` once and hands every caller the same
8
+ * result.
9
+ *
10
+ * A rejection is NOT retained. Each init is a multi-megabyte fetch over
11
+ * whatever connection the reader has, and a page navigating away mid-download
12
+ * aborts it, so a retained rejection would answer every later call with a
13
+ * failure the network has long since recovered from. Dropping it makes the
14
+ * next call start over, which is what a reader who opens a document a minute
15
+ * later needs.
16
+ */
17
+ export function createReadyGate(
18
+ inits: readonly (() => Promise<unknown>)[],
19
+ ): () => Promise<void> {
20
+ let readying: Promise<void> | null = null;
21
+ return () => {
22
+ readying ??= Promise.all(inits.map((init) => init()))
23
+ .then(() => undefined)
24
+ .catch((err: unknown) => {
25
+ readying = null;
26
+ throw err;
27
+ });
28
+ return readying;
29
+ };
30
+ }