@brass-build/client 0.4.0 → 0.5.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 (327) hide show
  1. package/AGENTS.md +142 -41
  2. package/CHANGELOG.md +93 -4
  3. package/README.md +666 -179
  4. package/dist/acting-organization.d.ts.map +1 -1
  5. package/dist/acting-organization.js +6 -11
  6. package/dist/acting-organization.js.map +1 -1
  7. package/dist/api-fetch.d.ts +8 -0
  8. package/dist/api-fetch.d.ts.map +1 -1
  9. package/dist/api-fetch.js +35 -8
  10. package/dist/api-fetch.js.map +1 -1
  11. package/dist/batched-get.d.ts +11 -0
  12. package/dist/batched-get.d.ts.map +1 -0
  13. package/dist/batched-get.js +92 -0
  14. package/dist/batched-get.js.map +1 -0
  15. package/dist/call-service.d.ts +4 -0
  16. package/dist/call-service.d.ts.map +1 -1
  17. package/dist/call-service.js +66 -3
  18. package/dist/call-service.js.map +1 -1
  19. package/dist/client.d.ts +51 -2
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +982 -124
  22. package/dist/client.js.map +1 -1
  23. package/dist/document-checkpoints.d.ts +25 -2
  24. package/dist/document-checkpoints.d.ts.map +1 -1
  25. package/dist/document-checkpoints.js +258 -61
  26. package/dist/document-checkpoints.js.map +1 -1
  27. package/dist/document-commands.d.ts.map +1 -1
  28. package/dist/document-commands.js +5 -3
  29. package/dist/document-commands.js.map +1 -1
  30. package/dist/document-contents.d.ts +10 -0
  31. package/dist/document-contents.d.ts.map +1 -1
  32. package/dist/document-contents.js +23 -1
  33. package/dist/document-contents.js.map +1 -1
  34. package/dist/document-set-subscription.d.ts +56 -0
  35. package/dist/document-set-subscription.d.ts.map +1 -0
  36. package/dist/document-set-subscription.js +240 -0
  37. package/dist/document-set-subscription.js.map +1 -0
  38. package/dist/document-signals.d.ts +6 -0
  39. package/dist/document-signals.d.ts.map +1 -1
  40. package/dist/document-signals.js +31 -1
  41. package/dist/document-signals.js.map +1 -1
  42. package/dist/document-streams.d.ts +36 -2
  43. package/dist/document-streams.d.ts.map +1 -1
  44. package/dist/document-streams.js +69 -18
  45. package/dist/document-streams.js.map +1 -1
  46. package/dist/document-thumbnail-capture.d.ts +12 -2
  47. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  48. package/dist/document-thumbnail-capture.js +103 -27
  49. package/dist/document-thumbnail-capture.js.map +1 -1
  50. package/dist/documents-namespace.d.ts +92 -10
  51. package/dist/documents-namespace.d.ts.map +1 -1
  52. package/dist/documents-namespace.js +285 -39
  53. package/dist/documents-namespace.js.map +1 -1
  54. package/dist/documents.d.ts +173 -8
  55. package/dist/documents.d.ts.map +1 -1
  56. package/dist/documents.js +1778 -310
  57. package/dist/documents.js.map +1 -1
  58. package/dist/errors.d.ts +3 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js.map +1 -1
  61. package/dist/fetch-retry.d.ts +1 -0
  62. package/dist/fetch-retry.d.ts.map +1 -1
  63. package/dist/fetch-retry.js +2 -0
  64. package/dist/fetch-retry.js.map +1 -1
  65. package/dist/file-choice.d.ts +2 -0
  66. package/dist/file-choice.d.ts.map +1 -0
  67. package/dist/file-choice.js +27 -0
  68. package/dist/file-choice.js.map +1 -0
  69. package/dist/guest-link.d.ts +18 -1
  70. package/dist/guest-link.d.ts.map +1 -1
  71. package/dist/guest-link.js +329 -1
  72. package/dist/guest-link.js.map +1 -1
  73. package/dist/handle-types.d.ts +67 -5
  74. package/dist/handle-types.d.ts.map +1 -1
  75. package/dist/handles.d.ts +15 -19
  76. package/dist/handles.d.ts.map +1 -1
  77. package/dist/handles.js +231 -76
  78. package/dist/handles.js.map +1 -1
  79. package/dist/hosted-ui.d.ts +61 -0
  80. package/dist/hosted-ui.d.ts.map +1 -1
  81. package/dist/hosted-ui.js +94 -42
  82. package/dist/hosted-ui.js.map +1 -1
  83. package/dist/import-contract.d.ts +74 -0
  84. package/dist/import-contract.d.ts.map +1 -0
  85. package/dist/import-contract.js +163 -0
  86. package/dist/import-contract.js.map +1 -0
  87. package/dist/import-guest.d.ts +9 -0
  88. package/dist/import-guest.d.ts.map +1 -0
  89. package/dist/import-guest.js +155 -0
  90. package/dist/import-guest.js.map +1 -0
  91. package/dist/import-report.d.ts +17 -0
  92. package/dist/import-report.d.ts.map +1 -0
  93. package/dist/import-report.js +7 -0
  94. package/dist/import-report.js.map +1 -0
  95. package/dist/importer-wait.d.ts +2 -0
  96. package/dist/importer-wait.d.ts.map +1 -0
  97. package/dist/importer-wait.js +5 -0
  98. package/dist/importer-wait.js.map +1 -0
  99. package/dist/importer.d.ts +18 -4
  100. package/dist/importer.d.ts.map +1 -1
  101. package/dist/importer.js +322 -105
  102. package/dist/importer.js.map +1 -1
  103. package/dist/in-development.d.ts +2 -0
  104. package/dist/in-development.d.ts.map +1 -0
  105. package/dist/in-development.js +10 -0
  106. package/dist/in-development.js.map +1 -0
  107. package/dist/index.d.ts +16 -8
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +24 -8
  110. package/dist/index.js.map +1 -1
  111. package/dist/init-keys.d.ts +1 -1
  112. package/dist/init-keys.d.ts.map +1 -1
  113. package/dist/init-keys.js +6 -1
  114. package/dist/init-keys.js.map +1 -1
  115. package/dist/input-documents.d.ts +67 -0
  116. package/dist/input-documents.d.ts.map +1 -0
  117. package/dist/input-documents.js +693 -0
  118. package/dist/input-documents.js.map +1 -0
  119. package/dist/inputs-status.d.ts +80 -0
  120. package/dist/inputs-status.d.ts.map +1 -0
  121. package/dist/inputs-status.js +460 -0
  122. package/dist/inputs-status.js.map +1 -0
  123. package/dist/inputs.d.ts +2 -0
  124. package/dist/inputs.d.ts.map +1 -0
  125. package/dist/inputs.js +793 -0
  126. package/dist/inputs.js.map +1 -0
  127. package/dist/internal.d.ts +6 -1
  128. package/dist/internal.d.ts.map +1 -1
  129. package/dist/internal.js +4 -1
  130. package/dist/internal.js.map +1 -1
  131. package/dist/join-content.d.ts +33 -0
  132. package/dist/join-content.d.ts.map +1 -0
  133. package/dist/join-content.js +114 -0
  134. package/dist/join-content.js.map +1 -0
  135. package/dist/join-stamp.d.ts +3 -0
  136. package/dist/join-stamp.d.ts.map +1 -0
  137. package/dist/join-stamp.js +12 -0
  138. package/dist/join-stamp.js.map +1 -0
  139. package/dist/manifest-drift.d.ts +9 -0
  140. package/dist/manifest-drift.d.ts.map +1 -0
  141. package/dist/manifest-drift.js +46 -0
  142. package/dist/manifest-drift.js.map +1 -0
  143. package/dist/open-document.d.ts +109 -2
  144. package/dist/open-document.d.ts.map +1 -1
  145. package/dist/open-document.js +1822 -811
  146. package/dist/open-document.js.map +1 -1
  147. package/dist/page-restore.d.ts +12 -0
  148. package/dist/page-restore.d.ts.map +1 -0
  149. package/dist/page-restore.js +23 -0
  150. package/dist/page-restore.js.map +1 -0
  151. package/dist/placement.d.ts +13 -0
  152. package/dist/placement.d.ts.map +1 -0
  153. package/dist/placement.js +34 -0
  154. package/dist/placement.js.map +1 -0
  155. package/dist/presence.d.ts +1 -0
  156. package/dist/presence.d.ts.map +1 -1
  157. package/dist/presence.js +16 -4
  158. package/dist/presence.js.map +1 -1
  159. package/dist/read-only-document.d.ts +87 -0
  160. package/dist/read-only-document.d.ts.map +1 -0
  161. package/dist/read-only-document.js +4 -0
  162. package/dist/read-only-document.js.map +1 -0
  163. package/dist/record-paging.d.ts +63 -6
  164. package/dist/record-paging.d.ts.map +1 -1
  165. package/dist/record-paging.js +122 -29
  166. package/dist/record-paging.js.map +1 -1
  167. package/dist/references.d.ts +40 -0
  168. package/dist/references.d.ts.map +1 -0
  169. package/dist/references.js +135 -0
  170. package/dist/references.js.map +1 -0
  171. package/dist/request-lane.d.ts +5 -0
  172. package/dist/request-lane.d.ts.map +1 -0
  173. package/dist/request-lane.js +48 -0
  174. package/dist/request-lane.js.map +1 -0
  175. package/dist/sealed-arrow.d.ts +170 -0
  176. package/dist/sealed-arrow.d.ts.map +1 -0
  177. package/dist/sealed-arrow.js +769 -0
  178. package/dist/sealed-arrow.js.map +1 -0
  179. package/dist/sealed-cache.d.ts +21 -0
  180. package/dist/sealed-cache.d.ts.map +1 -0
  181. package/dist/sealed-cache.js +247 -0
  182. package/dist/sealed-cache.js.map +1 -0
  183. package/dist/sealed-encode.d.ts +87 -0
  184. package/dist/sealed-encode.d.ts.map +1 -0
  185. package/dist/sealed-encode.js +876 -0
  186. package/dist/sealed-encode.js.map +1 -0
  187. package/dist/sealed-handles.d.ts +45 -0
  188. package/dist/sealed-handles.d.ts.map +1 -0
  189. package/dist/sealed-handles.js +408 -0
  190. package/dist/sealed-handles.js.map +1 -0
  191. package/dist/sealed-store.d.ts +242 -0
  192. package/dist/sealed-store.d.ts.map +1 -0
  193. package/dist/sealed-store.js +1155 -0
  194. package/dist/sealed-store.js.map +1 -0
  195. package/dist/serializer.d.ts +6 -15
  196. package/dist/serializer.d.ts.map +1 -1
  197. package/dist/session-toolbar.d.ts +20 -5
  198. package/dist/session-toolbar.d.ts.map +1 -1
  199. package/dist/session-toolbar.js +419 -67
  200. package/dist/session-toolbar.js.map +1 -1
  201. package/dist/session.d.ts +38 -1
  202. package/dist/session.d.ts.map +1 -1
  203. package/dist/session.js +562 -79
  204. package/dist/session.js.map +1 -1
  205. package/dist/sha256.d.ts +3 -0
  206. package/dist/sha256.d.ts.map +1 -0
  207. package/dist/sha256.js +74 -0
  208. package/dist/sha256.js.map +1 -0
  209. package/dist/stable-handles.d.ts +19 -0
  210. package/dist/stable-handles.d.ts.map +1 -0
  211. package/dist/stable-handles.js +193 -0
  212. package/dist/stable-handles.js.map +1 -0
  213. package/dist/staged-seal.d.ts +2 -0
  214. package/dist/staged-seal.d.ts.map +1 -0
  215. package/dist/staged-seal.js +76 -0
  216. package/dist/staged-seal.js.map +1 -0
  217. package/dist/subscriber-delivery.d.ts +8 -0
  218. package/dist/subscriber-delivery.d.ts.map +1 -0
  219. package/dist/subscriber-delivery.js +31 -0
  220. package/dist/subscriber-delivery.js.map +1 -0
  221. package/dist/tail-op-import.d.ts +10 -0
  222. package/dist/tail-op-import.d.ts.map +1 -0
  223. package/dist/tail-op-import.js +33 -0
  224. package/dist/tail-op-import.js.map +1 -0
  225. package/dist/topic-lifecycle.d.ts +6 -0
  226. package/dist/topic-lifecycle.d.ts.map +1 -1
  227. package/dist/topic-lifecycle.js +32 -2
  228. package/dist/topic-lifecycle.js.map +1 -1
  229. package/dist/trial-replace-dialog.d.ts +10 -0
  230. package/dist/trial-replace-dialog.d.ts.map +1 -0
  231. package/dist/trial-replace-dialog.js +153 -0
  232. package/dist/trial-replace-dialog.js.map +1 -0
  233. package/dist/types.d.ts +8 -1
  234. package/dist/types.d.ts.map +1 -1
  235. package/dist/undo.d.ts +60 -5
  236. package/dist/undo.d.ts.map +1 -1
  237. package/dist/undo.js +344 -75
  238. package/dist/undo.js.map +1 -1
  239. package/dist/version-bar.d.ts +1 -0
  240. package/dist/version-bar.d.ts.map +1 -1
  241. package/dist/version-bar.js +2 -1
  242. package/dist/version-bar.js.map +1 -1
  243. package/dist/version.d.ts +2 -2
  244. package/dist/version.js +1 -1
  245. package/dist/ws-client.d.ts +21 -0
  246. package/dist/ws-client.d.ts.map +1 -1
  247. package/dist/ws-client.js +192 -8
  248. package/dist/ws-client.js.map +1 -1
  249. package/dist/ws-frame-limit.d.ts +1 -0
  250. package/dist/ws-frame-limit.d.ts.map +1 -1
  251. package/dist/ws-frame-limit.js.map +1 -1
  252. package/docs/app-manifest.md +121 -17
  253. package/docs/custom-chrome.md +24 -16
  254. package/docs/hosted-ui-advanced.md +38 -6
  255. package/docs/imported-documents.md +104 -51
  256. package/docs/presence.md +16 -4
  257. package/docs/schemas.md +8 -5
  258. package/docs/services.md +6 -6
  259. package/docs/streams.md +139 -150
  260. package/docs/text-fields.md +26 -19
  261. package/package.json +5 -2
  262. package/src/acting-organization.ts +6 -12
  263. package/src/api-fetch.ts +53 -3
  264. package/src/auth-flow.ts +1 -1
  265. package/src/batched-get.ts +117 -0
  266. package/src/call-service.ts +78 -3
  267. package/src/client.ts +1092 -121
  268. package/src/document-checkpoints.ts +307 -65
  269. package/src/document-commands.ts +5 -5
  270. package/src/document-contents.ts +27 -1
  271. package/src/document-set-subscription.ts +274 -0
  272. package/src/document-signals.ts +47 -5
  273. package/src/document-streams.ts +85 -16
  274. package/src/document-thumbnail-capture.ts +118 -32
  275. package/src/documents-namespace.ts +539 -84
  276. package/src/documents.ts +2017 -348
  277. package/src/errors.ts +17 -6
  278. package/src/fetch-retry.ts +5 -0
  279. package/src/file-choice.ts +26 -0
  280. package/src/guest-link.ts +362 -1
  281. package/src/handle-types.ts +73 -47
  282. package/src/handles.ts +253 -83
  283. package/src/hosted-ui.ts +211 -50
  284. package/src/import-contract.ts +227 -0
  285. package/src/import-guest.ts +193 -0
  286. package/src/import-report.ts +24 -0
  287. package/src/importer-wait.ts +4 -0
  288. package/src/importer.ts +386 -131
  289. package/src/in-development.ts +10 -0
  290. package/src/index.ts +76 -16
  291. package/src/init-keys.ts +6 -1
  292. package/src/input-documents.ts +819 -0
  293. package/src/inputs-status.ts +598 -0
  294. package/src/inputs.ts +1090 -0
  295. package/src/internal.ts +7 -0
  296. package/src/join-content.ts +135 -0
  297. package/src/join-stamp.ts +12 -0
  298. package/src/manifest-drift.ts +54 -0
  299. package/src/open-document.ts +2346 -930
  300. package/src/page-restore.ts +38 -0
  301. package/src/placement.ts +54 -0
  302. package/src/presence.ts +21 -5
  303. package/src/read-only-document.ts +133 -0
  304. package/src/record-paging.ts +147 -25
  305. package/src/references.ts +207 -0
  306. package/src/request-lane.ts +59 -0
  307. package/src/sealed-arrow.ts +843 -0
  308. package/src/sealed-cache.ts +267 -0
  309. package/src/sealed-encode.ts +1015 -0
  310. package/src/sealed-handles.ts +486 -0
  311. package/src/sealed-store.ts +1330 -0
  312. package/src/serializer.ts +8 -20
  313. package/src/session-toolbar.ts +463 -72
  314. package/src/session.ts +740 -99
  315. package/src/sha256.ts +65 -0
  316. package/src/stable-handles.ts +235 -0
  317. package/src/staged-seal.ts +90 -0
  318. package/src/subscriber-delivery.ts +37 -0
  319. package/src/tail-op-import.ts +30 -0
  320. package/src/topic-lifecycle.ts +38 -2
  321. package/src/trial-replace-dialog.ts +163 -0
  322. package/src/types.ts +6 -6
  323. package/src/undo.ts +386 -73
  324. package/src/version-bar.ts +5 -1
  325. package/src/version.ts +1 -1
  326. package/src/ws-client.ts +208 -16
  327. package/src/ws-frame-limit.ts +1 -0
@@ -23,12 +23,14 @@ const STYLE_ID = 'brass-toolbar-styles';
23
23
  const CSS = `
24
24
  .brass-ui { --brass-surface:#161b22; --brass-surface-2:#1f2730;
25
25
  --brass-fg:#e6edf3; --brass-fg-muted:#9da7b3; --brass-border:#2a313b;
26
- --brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-danger:#e5534b;
26
+ --brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-accent-soft:#1c2b45;
27
+ --brass-danger:#e5534b;
27
28
  color:var(--brass-fg);
28
29
  font:14px system-ui,-apple-system,Segoe UI,Roboto,sans-serif; box-sizing:border-box; }
29
30
  @media (prefers-color-scheme: light) { .brass-ui {
30
31
  --brass-surface:#fff; --brass-surface-2:#f1f3f5; --brass-fg:#1b1f24;
31
- --brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-danger:#cf222e; } }
32
+ --brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-accent-soft:#e6effd;
33
+ --brass-danger:#cf222e; } }
32
34
  .brass-ui *, .brass-bar * { box-sizing:border-box; }
33
35
  /* Host apps commonly set a global button min-height for touch targets, which
34
36
  overrides the avatar's height regardless of specificity and stretches the
@@ -61,6 +63,7 @@ const CSS = `
61
63
  .brass-bar .b-tools { order:2; flex:1; flex-basis:auto;
62
64
  justify-content:center; } .brass-bar .b-right { order:3; margin-left:auto; } }
63
65
  .b-menu { display:inline-flex; }
66
+ .b-menu[hidden] { display:none; }
64
67
  /* A bare hamburger glyph, no button chrome (background / border / radius), so it
65
68
  reads as an icon sitting beside the document title. The 36px box is only an
66
69
  invisible tap target; the glyph is sized up so it holds its weight next to the
@@ -88,6 +91,10 @@ const CSS = `
88
91
  declared here. */
89
92
  .b-pop .b-sep { height:0; border:none; border-top:1px solid var(--brass-border);
90
93
  margin:4px 2px; }
94
+ /* A line of fact in a menu (when a document minted without an account
95
+ expires): the same inset as an entry, in the muted tone, and no hover. */
96
+ .b-pop .b-note { padding:8px 10px; color:var(--brass-fg-muted); font-size:12px;
97
+ line-height:1.3; }
91
98
  /* [hidden] must beat the block rule above (the account menu swaps its body
92
99
  between signed-in actions and the guest's "Sign in"). */
93
100
  .b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden],
@@ -113,7 +120,7 @@ const CSS = `
113
120
  border:1px solid var(--brass-accent); border-radius:6px; padding:0 6px;
114
121
  margin:0; cursor:pointer; font-family:inherit; overflow:hidden;
115
122
  text-overflow:ellipsis; white-space:nowrap; }
116
- .b-branch:hover { background:var(--brass-accent); color:var(--brass-accent-fg); }
123
+ .b-branch:hover:not(:disabled) { background:var(--brass-accent); color:var(--brass-accent-fg); }
117
124
  .b-loc { font-size:11px; line-height:1.2; color:var(--brass-fg-muted);
118
125
  background:none; border:none; border-radius:0; padding:1px 3px; min-height:0; cursor:pointer; white-space:nowrap;
119
126
  font-family:inherit; }
@@ -121,7 +128,24 @@ const CSS = `
121
128
  .b-loc.filed::before { content:'📁 '; }
122
129
  .b-loc.loose { text-decoration:underline dashed; text-underline-offset:2px; }
123
130
  .b-loc.loose::before { content:'+ '; }
124
- .b-undo-redo { display:inline-flex; align-items:center; gap:6px; }
131
+ .b-undo-redo { display:inline-flex; align-items:center; gap:2px; }
132
+ .b-undo-compact { display:none; }
133
+ .b-tool.b-tool-icon { padding:6px; display:inline-flex; align-items:center; justify-content:center;
134
+ background:none; border-color:transparent; }
135
+ .b-tool.b-tool-icon:hover:not(:disabled) { background:var(--brass-surface-2); }
136
+ /* An icon button's name on hover or keyboard focus. A top-layer popover, so
137
+ no ancestor's overflow clips it. */
138
+ .b-tip { position:fixed; inset:auto; margin:0; padding:4px 8px; border:none;
139
+ border-radius:6px; background:var(--brass-fg); color:var(--brass-surface);
140
+ font-size:12px; line-height:1.3; white-space:nowrap; pointer-events:none;
141
+ display:none; z-index:2147483646; }
142
+ .b-tip:popover-open, .b-tip.b-tip-open { display:block; }
143
+ /* Narrow: the pair sits in the top row beside the avatar, so the tools row
144
+ holds only the app's items and collapses when it has none. */
145
+ @media (max-width:759.98px) {
146
+ .brass-bar .b-undo-compact { display:inline-flex; }
147
+ .brass-bar .b-undo-full { display:none; }
148
+ .brass-bar:not(.b-app-tools) .b-tools { display:none !important; } }
125
149
  .b-tool { background:var(--brass-surface-2); color:var(--brass-fg);
126
150
  border:1px solid var(--brass-border); border-radius:8px; padding:8px 12px; cursor:pointer; font:inherit; }
127
151
  .b-tool:disabled { opacity:.5; cursor:default; }
@@ -143,6 +167,13 @@ const CSS = `
143
167
  /* The uploaded avatar image fills the circle; object-fit:cover keeps it
144
168
  undistorted. It sits over the monogram, which stays in the DOM as the
145
169
  fallback shown when no image is set or the image fails to load. */
170
+ /* The accent-filled controls (this avatar, the guest strip's sign-in, the
171
+ branch chip) restate their fill and text colour on hover, at a
172
+ specificity above a host's own button:hover:not(:disabled) whatever order the
173
+ two stylesheets load in. A host hover rule otherwise repaints the fill with
174
+ its own (often near-white) background under this white text. */
175
+ button.b-avatar:hover:not(:disabled) { background:var(--brass-accent);
176
+ color:var(--brass-accent-fg); }
146
177
  .b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
147
178
  .b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
148
179
  /* The account menu opens on the identity it acts for: the avatar again at
@@ -210,6 +241,23 @@ const CSS = `
210
241
  reports a problem, so it carries the danger tone rather than the muted one
211
242
  every other line uses. */
212
243
  .b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
244
+ /* The strip under the bar while the open document is one the platform will
245
+ remove (created without an account): it says so and carries the sign-in
246
+ that keeps the work. Tinted with the accent so it reads as a notice about
247
+ the document rather than as more chrome; the text takes the slack and the
248
+ button holds the trailing edge until a narrow width wraps it under. */
249
+ .b-guest { width:100%; display:none; flex-wrap:wrap; align-items:center;
250
+ justify-content:space-between; gap:6px 12px; padding:8px 14px; font-size:13px;
251
+ line-height:1.4; background:var(--brass-accent-soft); color:var(--brass-fg);
252
+ border-bottom:1px solid var(--brass-border); }
253
+ .b-guest.b-guest-on { display:flex; }
254
+ .b-guest-msg { flex:1 1 auto; min-width:0; }
255
+ .b-guest button { flex:0 0 auto; background:var(--brass-accent);
256
+ color:var(--brass-accent-fg); border:none; border-radius:6px; cursor:pointer;
257
+ font:inherit; font-size:13px; font-weight:600; padding:6px 12px; }
258
+ .b-guest button:hover:not(:disabled) { background:var(--brass-accent);
259
+ color:var(--brass-accent-fg); filter:brightness(1.1); }
260
+ .b-guest button:focus-visible { outline:2px solid var(--brass-fg); outline-offset:2px; }
213
261
  /* Historical-version preview bar: a full-width strip the toolbar shows below
214
262
  the bar whenever the active doc is previewing an earlier version. The strip
215
263
  itself is the platform-served version-bar iframe (the SDK owns only this
@@ -278,6 +326,12 @@ function el(tag, className) {
278
326
  node.className = className;
279
327
  return node;
280
328
  }
329
+ // A failed publish shows the server's own sentence, which is what the route
330
+ // answers a refusal with.
331
+ function publishFailureText(err) {
332
+ const message = err instanceof Error ? err.message.trim() : '';
333
+ return message === '' || message === 'Request failed' ? 'Publishing failed.' : message;
334
+ }
281
335
  function initialOf(user) {
282
336
  const label = user.display_name ?? user.email ?? user.sub;
283
337
  return label.trim().charAt(0).toUpperCase() || '?';
@@ -323,6 +377,26 @@ function renderCommandButton(cmd, asTool) {
323
377
  b.disabled = !cmd.enabled || cmd.running;
324
378
  return b;
325
379
  }
380
+ const COMMAND_ICONS = {
381
+ undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
382
+ redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/>',
383
+ };
384
+ // A toolbar command as an icon button, named by its label for assistive
385
+ // technology and on hover. A command with no icon keeps its text.
386
+ function commandGlyph(icon) {
387
+ return `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>`;
388
+ }
389
+ function renderCommandIcon(cmd) {
390
+ const icon = COMMAND_ICONS[cmd.id];
391
+ if (icon === undefined)
392
+ return renderCommandButton(cmd, true);
393
+ const b = el('button', 'b-tool b-tool-icon');
394
+ b.type = 'button';
395
+ b.setAttribute('aria-label', cmd.label);
396
+ b.innerHTML = commandGlyph(icon);
397
+ b.disabled = !cmd.enabled || cmd.running;
398
+ return b;
399
+ }
326
400
  // Place a top-layer (position:fixed) popover under its anchor, clamped to the
327
401
  // viewport so a wide one near an edge never runs off-screen. `align` picks the
328
402
  // edge it lines up with the anchor: `start` (left) or `end` (right).
@@ -458,10 +532,20 @@ function buildFileMenu(deps) {
458
532
  menu.trigger.textContent = '☰';
459
533
  const render = () => {
460
534
  const rows = [];
535
+ const expiresAt = deps.session.document?.expiresAt ?? null;
536
+ if (expiresAt !== null) {
537
+ const note = el('div', 'b-note b-expiry');
538
+ note.textContent = expiresInLabel(expiresAt);
539
+ rows.push(note);
540
+ }
461
541
  let group = null;
462
542
  for (const c of deps.session.commands) {
463
543
  if (c.surface !== 'menu')
464
544
  continue;
545
+ // A session command that is not enabled is not offered, so it is left
546
+ // out rather than greyed.
547
+ if (c.group === 'session' && !c.enabled)
548
+ continue;
465
549
  // A rule wherever the family changes: the entries that put a
466
550
  // different document on screen, then the ones about this document.
467
551
  if (group !== null && c.group !== group) {
@@ -478,6 +562,9 @@ function buildFileMenu(deps) {
478
562
  rows.push(b);
479
563
  }
480
564
  menu.pop.replaceChildren(...rows);
565
+ if (rows.length === 0)
566
+ menu.close();
567
+ menu.root.hidden = rows.length === 0;
481
568
  };
482
569
  render();
483
570
  const unsub = deps.session.subscribe(render);
@@ -552,12 +639,12 @@ function buildDocumentTitle(deps) {
552
639
  if (record.location_label !== null) {
553
640
  loc.textContent = record.location_label;
554
641
  loc.className = 'b-loc filed';
555
- loc.title = 'Move or rename this document';
642
+ loc.title = 'Move or rename';
556
643
  }
557
644
  else {
558
645
  loc.textContent = 'Add to project';
559
646
  loc.className = 'b-loc loose';
560
- loc.title = 'File this document in a project';
647
+ loc.title = 'Add to a project';
561
648
  }
562
649
  loc.hidden = false;
563
650
  }
@@ -580,15 +667,23 @@ function buildDocumentTitle(deps) {
580
667
  // Three states, not two. A bound doc whose name has not resolved yet
581
668
  // (a fresh create the server just numbered, a create/open still settling)
582
669
  // and an open in flight (`status` 'loading', `document` still null) both
583
- // leave the name UNKNOWN: render nothing rather than assert "No document",
670
+ // leave the name UNKNOWN: render nothing rather than assert "Nothing open",
584
671
  // the same way the session reports 'unknown' auth as a neutral state
585
672
  // instead of flashing 'signed-out'. Only the genuine empty state (no doc,
586
- // not loading) reads "No document".
673
+ // not loading) reads "Nothing open".
674
+ // A link the app opens only for signed-in people names the refusal,
675
+ // since the account control beside it is where the visitor signs in.
676
+ const refusal = !doc &&
677
+ session.status === 'error' &&
678
+ session.error?.code === 'sign_in_required' &&
679
+ deps.onLink?.() === true
680
+ ? session.error.message
681
+ : null;
587
682
  const name = doc
588
683
  ? doc.name
589
684
  : session.status === 'loading'
590
685
  ? null
591
- : 'No document';
686
+ : (refusal ?? 'Nothing open');
592
687
  if (name === null) {
593
688
  title.hidden = true;
594
689
  }
@@ -627,14 +722,97 @@ function buildDocumentTitle(deps) {
627
722
  },
628
723
  };
629
724
  }
630
- function buildUndoRedo(deps) {
725
+ // Shows the aria-label of the icon button under the pointer or keyboard
726
+ // focus inside `root`, below the button and clamped to the viewport.
727
+ // One tip per document, outside every piece, so a piece's own children stay
728
+ // what it renders.
729
+ let sharedTip = null;
730
+ function iconTip() {
731
+ if (sharedTip?.isConnected)
732
+ return sharedTip;
733
+ sharedTip = el('div', 'brass-ui b-tip');
734
+ sharedTip.setAttribute('aria-hidden', 'true');
735
+ sharedTip.setAttribute('popover', 'manual');
736
+ document.body.append(sharedTip);
737
+ return sharedTip;
738
+ }
739
+ function attachIconTooltips(root) {
740
+ const tip = iconTip();
741
+ const api = tip;
742
+ let shownFor = null;
743
+ const show = (btn) => {
744
+ const label = btn.getAttribute('aria-label');
745
+ if (!label)
746
+ return;
747
+ shownFor = btn;
748
+ tip.textContent = label;
749
+ if (typeof api.showPopover === 'function') {
750
+ try {
751
+ if (!tip.matches(':popover-open'))
752
+ api.showPopover();
753
+ }
754
+ catch {
755
+ tip.classList.add('b-tip-open');
756
+ }
757
+ }
758
+ else
759
+ tip.classList.add('b-tip-open');
760
+ const r = btn.getBoundingClientRect();
761
+ const w = tip.offsetWidth;
762
+ const left = Math.max(4, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 4));
763
+ tip.style.left = `${left}px`;
764
+ tip.style.top = `${r.bottom + 6}px`;
765
+ };
766
+ const hide = () => {
767
+ shownFor = null;
768
+ tip.classList.remove('b-tip-open');
769
+ try {
770
+ if (tip.matches(':popover-open'))
771
+ api.hidePopover?.();
772
+ }
773
+ catch {
774
+ // Not showing.
775
+ }
776
+ };
777
+ const iconOf = (t) => t instanceof Element ? t.closest('.b-tool-icon') : null;
778
+ root.addEventListener('pointerover', (e) => {
779
+ const btn = iconOf(e.target);
780
+ if (btn && e.pointerType === 'mouse')
781
+ show(btn);
782
+ });
783
+ root.addEventListener('pointerout', (e) => {
784
+ if (iconOf(e.target) === shownFor && iconOf(e.relatedTarget) !== shownFor)
785
+ hide();
786
+ });
787
+ root.addEventListener('pointerdown', hide);
788
+ root.addEventListener('focusin', (e) => {
789
+ const btn = iconOf(e.target);
790
+ if (btn && btn.matches(':focus-visible'))
791
+ show(btn);
792
+ });
793
+ root.addEventListener('focusout', hide);
794
+ return { hide };
795
+ }
796
+ // `full` and `compact` are the toolbar's two copies, which the narrow-width
797
+ // rules swap. `standalone` is the one an app mounts itself: no copy stands in
798
+ // for it at a narrow width, so those rules must not hide it.
799
+ function buildUndoRedo(deps, variant = 'full') {
631
800
  injectStyles(document);
632
- const root = el('div', 'brass-ui b-undo-redo');
801
+ const root = el('div', variant === 'standalone' ? 'brass-ui b-undo-redo' : `brass-ui b-undo-redo b-undo-${variant}`);
802
+ const tips = attachIconTooltips(root);
633
803
  const render = () => {
634
- root.replaceChildren(...deps.session.commands
635
- .filter((c) => c.surface === 'toolbar')
804
+ // The pair shows only while one of them can act: before the first edit, on
805
+ // a read-only document, and with nothing open they would only ever show
806
+ // disabled, and rendering none lets the bar give their row back.
807
+ const commands = deps.session.commands.filter((c) => c.surface === 'toolbar');
808
+ tips.hide();
809
+ if (!commands.some((c) => c.enabled)) {
810
+ root.replaceChildren();
811
+ return;
812
+ }
813
+ root.replaceChildren(...commands
636
814
  .map((c) => {
637
- const b = renderCommandButton(c, true);
815
+ const b = renderCommandIcon(c);
638
816
  b.onclick = () => void c.run();
639
817
  return b;
640
818
  }));
@@ -645,6 +823,7 @@ function buildUndoRedo(deps) {
645
823
  root,
646
824
  destroy() {
647
825
  unsub();
826
+ tips.hide();
648
827
  },
649
828
  };
650
829
  }
@@ -658,8 +837,26 @@ function buildPresenceRoster(deps) {
658
837
  // people as present on the one they are looking at.
659
838
  let boundDoc = null;
660
839
  let presenceOff = null;
840
+ // The roster lists every other connection, so the reader's own other tabs,
841
+ // and a page they navigated away from until its socket closes, are in it.
842
+ // A pip stands for a person: one per sub, and never the reader. A visitor
843
+ // on a shared link has an empty sub, so each such connection is its own
844
+ // pip.
845
+ const collaborators = (participants) => {
846
+ const own = deps.getUser?.()?.sub;
847
+ const seen = new Set();
848
+ return participants.filter((p) => {
849
+ if (own !== undefined && p.sub === own)
850
+ return false;
851
+ const person = p.sub !== '' ? p.sub : `connection:${p.connectionId}`;
852
+ if (seen.has(person))
853
+ return false;
854
+ seen.add(person);
855
+ return true;
856
+ });
857
+ };
661
858
  const renderRoster = (participants) => {
662
- root.replaceChildren(...participants.map((p) => {
859
+ root.replaceChildren(...collaborators(participants).map((p) => {
663
860
  const pip = el('span', 'b-pip');
664
861
  pip.style.background = p.color;
665
862
  const label = p.displayName ?? p.sub ?? '?';
@@ -714,6 +911,28 @@ function buildPresenceRoster(deps) {
714
911
  },
715
912
  };
716
913
  }
914
+ // The guest's account-menu entry, and its wording on a document created
915
+ // without an account, where signing in is what keeps it.
916
+ const SIGN_IN_LABEL = 'Sign in';
917
+ const SIGN_IN_TO_KEEP_LABEL = 'Sign in to keep your work';
918
+ const KEEP_LABEL = 'Keep';
919
+ const DAY_MS = 24 * 60 * 60 * 1000;
920
+ // The file menu's line for a document the platform will remove: whole days
921
+ // until then, rounded up, so the last day still reads as one.
922
+ export function expiresInLabel(expiresAt, now = Date.now()) {
923
+ const days = Math.max(1, Math.ceil((expiresAt.getTime() - now) / DAY_MS));
924
+ return `Your work will be lost in ${days} ${days === 1 ? 'day' : 'days'}`;
925
+ }
926
+ // Someone else's temporary work, to a signed-in reader who cannot keep it.
927
+ export function removedInLabel(expiresAt, now = Date.now()) {
928
+ const days = Math.max(1, Math.ceil((expiresAt.getTime() - now) / DAY_MS));
929
+ return `This work will be removed in ${days} ${days === 1 ? 'day' : 'days'}`;
930
+ }
931
+ // The strip's sentence: the same count as the file menu's line, led by the
932
+ // state that explains it.
933
+ export function notSignedInLabel(expiresAt, now = Date.now()) {
934
+ return `You are not signed in. ${expiresInLabel(expiresAt, now)}.`;
935
+ }
717
936
  // A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
718
937
  // in the SVG namespace because `createElement('svg')` yields an HTML element
719
938
  // that won't render.
@@ -803,13 +1022,23 @@ function buildDriveSync(deps) {
803
1022
  // covering the gap between a click here and the first `publishing` frame
804
1023
  // arriving; the two combine as `isPublishing` in `render`.
805
1024
  let publishing = false;
806
- let publishError = false;
1025
+ // The server's refusal of the last publish from this panel, shown where
1026
+ // the resting sentence sits until the next attempt.
1027
+ let publishError = null;
807
1028
  let publishTimer = null;
808
1029
  // Set on a click, cleared by the next publish-state frame. Between the two,
809
1030
  // the held state describes the PREVIOUS attempt, and rendering it would
810
1031
  // report the last failure over the publish just started.
811
1032
  let publishStateStale = false;
812
1033
  let currentStatus = null;
1034
+ // The panel is asking whether to publish over the drive's newer version.
1035
+ let confirmingOverwrite = false;
1036
+ pop.addEventListener('toggle', (event) => {
1037
+ if (event.newState === 'closed' && confirmingOverwrite) {
1038
+ confirmingOverwrite = false;
1039
+ render(currentStatus);
1040
+ }
1041
+ });
813
1042
  const clearPublishing = () => {
814
1043
  publishing = false;
815
1044
  // The stale mark is cleared with the hold it belongs to. It is otherwise
@@ -829,6 +1058,16 @@ function buildDriveSync(deps) {
829
1058
  // one.
830
1059
  const renderPublishOffer = (target) => {
831
1060
  root.hidden = false;
1061
+ if (target.unpublishable) {
1062
+ // Nothing to publish, so the icon asks for no attention; the panel
1063
+ // names the drive and says why the file cannot go to it.
1064
+ root.classList.remove('has-changes', 'is-broken');
1065
+ button.title = `Cannot publish to ${target.driveName}`;
1066
+ const p = el('p', 'b-drive-unreadable');
1067
+ p.textContent = target.detail;
1068
+ pop.replaceChildren(p);
1069
+ return;
1070
+ }
832
1071
  // A document the drive has never received is as far out of sync as one
833
1072
  // whose edits have not been published, so the icon carries the same
834
1073
  // attention an `ahead` document's does. What separates them is what the
@@ -842,7 +1081,8 @@ function buildDriveSync(deps) {
842
1081
  : state?.failed
843
1082
  ? `Publish to ${target.driveName} failed`
844
1083
  : `Publish to ${target.driveName}`;
845
- const failed = !inFlight && (state?.failed === true || target.publishFailed === true);
1084
+ const failed = !inFlight &&
1085
+ (publishError !== null || state?.failed === true || target.publishFailed === true);
846
1086
  const rows = [];
847
1087
  // One line, server-authored either way: the worker's phase copy while it
848
1088
  // runs, its account of the failure when it stopped, and the resting
@@ -852,21 +1092,21 @@ function buildDriveSync(deps) {
852
1092
  const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
853
1093
  p.textContent = inFlight
854
1094
  ? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
855
- : (state?.detail ?? target.detail);
1095
+ : (publishError ?? state?.detail ?? target.detail);
856
1096
  rows.push(p);
857
1097
  const pub = el('button');
858
1098
  pub.type = 'button';
859
1099
  pub.disabled = inFlight;
860
1100
  pub.textContent = inFlight
861
1101
  ? 'Publishing...'
862
- : publishError || failed
1102
+ : failed
863
1103
  ? `Publish to ${target.driveName}, retry`
864
1104
  : `Publish to ${target.driveName}`;
865
1105
  pub.onclick = () => {
866
1106
  const doc = deps.session.document;
867
1107
  if (!doc)
868
1108
  return;
869
- publishError = false;
1109
+ publishError = null;
870
1110
  publishing = true;
871
1111
  publishStateStale = true;
872
1112
  // The same safety net the mirror panel keeps: a publish that neither
@@ -877,9 +1117,9 @@ function buildDriveSync(deps) {
877
1117
  render(currentStatus);
878
1118
  }, PUBLISH_HOLD_MS);
879
1119
  render(currentStatus);
880
- void doc.pushToDrive().catch(() => {
1120
+ void doc.pushToDrive().catch((err) => {
881
1121
  clearPublishing();
882
- publishError = true;
1122
+ publishError = publishFailureText(err);
883
1123
  render(currentStatus);
884
1124
  });
885
1125
  };
@@ -888,6 +1128,8 @@ function buildDriveSync(deps) {
888
1128
  };
889
1129
  const render = (status) => {
890
1130
  currentStatus = status;
1131
+ if (status?.state !== 'diverged' || !status.canPush)
1132
+ confirmingOverwrite = false;
891
1133
  // A document that mirrors nothing can still have somewhere to go: its
892
1134
  // project may be linked to a drive that takes it as a new file, which is
893
1135
  // what an upload into a linked project sits waiting for. Offer that
@@ -910,7 +1152,7 @@ function buildDriveSync(deps) {
910
1152
  // local hold (the server frame already has `publishing: false`).
911
1153
  if (status.state === 'synced') {
912
1154
  clearPublishing();
913
- publishError = false;
1155
+ publishError = null;
914
1156
  }
915
1157
  root.hidden = false;
916
1158
  // Either a local click hold or the server's own `publishing` flag means a
@@ -973,6 +1215,11 @@ function buildDriveSync(deps) {
973
1215
  warn.textContent = status.sourceUnreadable;
974
1216
  rows.push(warn);
975
1217
  }
1218
+ if (publishError !== null && !isPublishing) {
1219
+ const warn = el('p', 'b-drive-unreadable');
1220
+ warn.textContent = publishError;
1221
+ rows.push(warn);
1222
+ }
976
1223
  if (isPublishing) {
977
1224
  // Held while the publish is in flight (a local click hold, or the doc's
978
1225
  // pending work-status), until it reaches synced (see `isPublishing`).
@@ -983,17 +1230,14 @@ function buildDriveSync(deps) {
983
1230
  rows.push(pub);
984
1231
  }
985
1232
  else if (status.canPush) {
986
- // `canPush` already encodes two-way + not broken + local edits + a
987
- // server-writable format, so this never offers a publish the server
988
- // would refuse.
989
- const pub = el('button');
990
- pub.type = 'button';
991
- pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
992
- pub.onclick = () => {
1233
+ // `canPush` is the server's publish decision, so this never offers a
1234
+ // publish the server would refuse.
1235
+ const startPublish = () => {
993
1236
  const doc = deps.session.document;
994
1237
  if (!doc)
995
1238
  return;
996
- publishError = false;
1239
+ confirmingOverwrite = false;
1240
+ publishError = null;
997
1241
  publishing = true;
998
1242
  // Safety net: if the publish neither errors nor reaches synced (the
999
1243
  // webhook never confirms), release the held state so the popover
@@ -1003,12 +1247,45 @@ function buildDriveSync(deps) {
1003
1247
  render(currentStatus);
1004
1248
  }, PUBLISH_HOLD_MS);
1005
1249
  render(currentStatus);
1006
- void doc.pushToDrive().catch(() => {
1250
+ void doc.pushToDrive().catch((err) => {
1007
1251
  clearPublishing();
1008
- publishError = true;
1252
+ publishError = publishFailureText(err);
1009
1253
  render(currentStatus);
1010
1254
  });
1011
1255
  };
1256
+ // A diverged document publishes over a drive version it never took, so
1257
+ // the panel asks first, in place.
1258
+ const overwrites = status.state === 'diverged';
1259
+ if (overwrites && confirmingOverwrite) {
1260
+ const ask = el('p', 'b-drive-unreadable');
1261
+ ask.textContent = status.keepsPriorVersions
1262
+ ? `Publishing makes this doc the latest version in ${driveName}. ${driveName} keeps the newer version in its history.`
1263
+ : `Publishing replaces the newer version in ${driveName} with this doc.`;
1264
+ const confirm = el('button');
1265
+ confirm.type = 'button';
1266
+ confirm.textContent = `Publish over ${driveName}`;
1267
+ confirm.onclick = startPublish;
1268
+ const cancel = el('button');
1269
+ cancel.type = 'button';
1270
+ cancel.textContent = 'Cancel';
1271
+ cancel.onclick = () => {
1272
+ confirmingOverwrite = false;
1273
+ render(currentStatus);
1274
+ };
1275
+ rows.push(ask, confirm, cancel);
1276
+ pop.replaceChildren(...rows);
1277
+ return;
1278
+ }
1279
+ const pub = el('button');
1280
+ pub.type = 'button';
1281
+ const verb = publishError !== null ? 'Publish failed, retry' : 'Publish changes';
1282
+ pub.textContent = overwrites ? `${verb}...` : verb;
1283
+ pub.onclick = overwrites
1284
+ ? () => {
1285
+ confirmingOverwrite = true;
1286
+ render(currentStatus);
1287
+ }
1288
+ : startPublish;
1012
1289
  rows.push(pub);
1013
1290
  }
1014
1291
  // Resolve a divergence via the hosted take-source / keep-both surface. Not
@@ -1106,7 +1383,6 @@ function buildAccountMenu(deps) {
1106
1383
  // landing and stands in when the app is working in none.
1107
1384
  const dashboardLink = el('a');
1108
1385
  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');
1109
- dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
1110
1386
  dashboardLink.target = '_blank';
1111
1387
  dashboardLink.rel = 'noopener noreferrer';
1112
1388
  dashboardLink.onclick = () => menu.close();
@@ -1130,7 +1406,8 @@ function buildAccountMenu(deps) {
1130
1406
  // meaningless, so the menu offers a single "Sign in" that takes the top
1131
1407
  // window through the app's normal sign-in and returns to this URL.
1132
1408
  const signInBtn = el('button');
1133
- 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');
1409
+ const signInLabel = document.createTextNode(SIGN_IN_LABEL);
1410
+ 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'), signInLabel);
1134
1411
  signInBtn.onclick = () => {
1135
1412
  menu.close();
1136
1413
  deps.signIn();
@@ -1161,6 +1438,11 @@ function buildAccountMenu(deps) {
1161
1438
  dashboardLink.hidden = !signedIn;
1162
1439
  signOutBtn.hidden = !signedIn;
1163
1440
  signInBtn.hidden = signedIn;
1441
+ // On a document that expires, signing in is what keeps it.
1442
+ signInLabel.data =
1443
+ deps.session?.document?.expiresAt != null
1444
+ ? SIGN_IN_TO_KEEP_LABEL
1445
+ : SIGN_IN_LABEL;
1164
1446
  // The organization line names what the app is acting in, so it is drawn
1165
1447
  // only when there is something to name: an app that has not needed an
1166
1448
  // organization yet has not resolved one, and resolving one to fill this
@@ -1169,10 +1451,11 @@ function buildAccountMenu(deps) {
1169
1451
  orgEl.textContent = organizationName;
1170
1452
  orgEl.title = organizationName;
1171
1453
  orgEl.hidden = organizationName === '';
1454
+ const dashboardBaseUrl = deps.dashboardBaseUrl();
1172
1455
  dashboardLink.href =
1173
1456
  signedIn && organization
1174
- ? `${deps.dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
1175
- : `${deps.dashboardBaseUrl}/home`;
1457
+ ? `${dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
1458
+ : `${dashboardBaseUrl}/home`;
1176
1459
  // Offered to every signed-in reader, including one the line has nothing
1177
1460
  // to name and one who belongs to a single organization. The surface
1178
1461
  // behind it both moves the app and creates an organization, so gating it
@@ -1190,18 +1473,70 @@ function buildAccountMenu(deps) {
1190
1473
  organization = next;
1191
1474
  render();
1192
1475
  });
1476
+ const unsubSession = deps.session?.subscribe(render);
1193
1477
  return {
1194
1478
  root: menu.root,
1195
1479
  destroy() {
1196
1480
  unsub();
1197
1481
  unsubOrganization?.();
1482
+ unsubSession?.();
1198
1483
  menu.destroy();
1199
1484
  },
1200
1485
  };
1201
1486
  }
1487
+ // The strip under the bar on a document created without an account. It is
1488
+ // drawn whenever the active doc carries `expiresAt`, re-read on every session
1489
+ // notification: a sign-in claims the document and clears the expiry, and the
1490
+ // active document can change under the chrome. Empty otherwise, so the row
1491
+ // costs no height on an ordinary document.
1492
+ function buildNotSignedIn(deps) {
1493
+ injectStyles(document);
1494
+ const root = el('div', 'brass-ui b-guest');
1495
+ root.setAttribute('role', 'status');
1496
+ const message = el('span', 'b-guest-msg');
1497
+ const signInBtn = el('button');
1498
+ signInBtn.type = 'button';
1499
+ signInBtn.textContent = SIGN_IN_LABEL;
1500
+ signInBtn.onclick = () => deps.signIn();
1501
+ const keepBtn = el('button');
1502
+ keepBtn.type = 'button';
1503
+ keepBtn.textContent = KEEP_LABEL;
1504
+ keepBtn.onclick = () => deps.keep?.();
1505
+ const render = () => {
1506
+ const expiresAt = deps.session.document?.expiresAt ?? null;
1507
+ if (expiresAt === null) {
1508
+ root.classList.remove('b-guest-on');
1509
+ root.replaceChildren();
1510
+ return;
1511
+ }
1512
+ if ((deps.getUser?.() ?? null) === null) {
1513
+ message.textContent = notSignedInLabel(expiresAt);
1514
+ root.replaceChildren(message, signInBtn);
1515
+ }
1516
+ else if (deps.canKeep?.() === true && deps.keep !== undefined) {
1517
+ message.textContent = `${expiresInLabel(expiresAt)}.`;
1518
+ root.replaceChildren(message, keepBtn);
1519
+ }
1520
+ else {
1521
+ message.textContent = `${removedInLabel(expiresAt)}.`;
1522
+ root.replaceChildren(message);
1523
+ }
1524
+ root.classList.add('b-guest-on');
1525
+ };
1526
+ render();
1527
+ const unsub = deps.session.subscribe(render);
1528
+ const unsubUser = deps.onUserChange?.(() => render());
1529
+ return {
1530
+ root,
1531
+ destroy() {
1532
+ unsub();
1533
+ unsubUser?.();
1534
+ },
1535
+ };
1536
+ }
1202
1537
  // The historical-version preview bar. When the active doc is previewing an
1203
1538
  // earlier version (a hosted `history`-initiated checkout: `doc.readOnly` is
1204
- // true and `doc.version` is set), the SDK mounts the platform-served
1539
+ // true and `doc.previewVersion` is set), the SDK mounts the platform-served
1205
1540
  // `/embed/version-bar` inline as a strip and routes its intents (Prev / Next
1206
1541
  // checkout, return-to-latest, browse, branch-from) to the doc's internal
1207
1542
  // methods. Delivered as a server embed so the platform owns and grows the
@@ -1212,7 +1547,7 @@ function buildVersionPreview(deps) {
1212
1547
  injectStyles(document);
1213
1548
  const root = el('div', 'brass-ui b-version');
1214
1549
  root.setAttribute('role', 'group');
1215
- root.setAttribute('aria-label', 'Document version');
1550
+ root.setAttribute('aria-label', 'Version');
1216
1551
  let frame = null;
1217
1552
  // Once a preview starts (a checkout), the bar owns a browsing session that
1218
1553
  // stays open, including after returning to the live head, until the user
@@ -1224,7 +1559,7 @@ function buildVersionPreview(deps) {
1224
1559
  // The version the reader dismissed the bar at, when the document was left
1225
1560
  // holding one. A document that cannot return to its head (one pinned to a
1226
1561
  // version) stays on it, so without this the next render reads a non-null
1227
- // `doc.version` and latches the bar the reader just dismissed straight back
1562
+ // `doc.previewVersion` and latches the bar the reader just dismissed straight back
1228
1563
  // on. A dismissal at the live head records nothing: there is no version
1229
1564
  // left to latch on, and the head is where the next browse-in-the-app
1230
1565
  // request arrives, so a record there would swallow it.
@@ -1250,12 +1585,15 @@ function buildVersionPreview(deps) {
1250
1585
  throw err;
1251
1586
  }
1252
1587
  };
1253
- const frameDeps = {
1254
- dashboardBaseUrl: deps.dashboardBaseUrl,
1255
- ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
1588
+ // Assembled when the bar opens rather than at mount, so the frame's origin
1589
+ // and theme are the ones `Brass.init()` settled (see `AccountMenuDeps`).
1590
+ const frameDeps = () => ({
1591
+ dashboardBaseUrl: deps.dashboardBaseUrl(),
1592
+ appId: deps.appId?.(),
1593
+ ...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme() } : {}),
1256
1594
  // Read the live active document at call time: a bridge intent lands
1257
1595
  // async, so it must not close over a doc that may have been superseded.
1258
- currentVersion: () => session.document?.version ?? null,
1596
+ currentVersion: () => session.document?.previewVersion ?? null,
1259
1597
  checkoutVersion: async (version) => {
1260
1598
  const doc = session.document;
1261
1599
  if (!doc)
@@ -1286,7 +1624,7 @@ function buildVersionPreview(deps) {
1286
1624
  // flag is cleared too, or the next render would latch the bar back on.
1287
1625
  const doc = session.document;
1288
1626
  browsing = false;
1289
- dismissedAt = doc?.version ?? undefined;
1627
+ dismissedAt = doc?.previewVersion ?? undefined;
1290
1628
  doc?._setVersionBrowsing(false);
1291
1629
  // Leaving the bar means leaving the preview: the bar is the only
1292
1630
  // surface that returns a document to its head, so a dismissal that
@@ -1299,7 +1637,7 @@ function buildVersionPreview(deps) {
1299
1637
  // pick sets only `version`, so a stale `dismissedAt` equal to it would
1300
1638
  // otherwise leave the reader checked out, read-only, with no bar and
1301
1639
  // no exit).
1302
- if (doc && doc.version !== null) {
1640
+ if (doc && doc.previewVersion !== null) {
1303
1641
  void attempt(doc, () => doc.returnToHead())
1304
1642
  .then(() => {
1305
1643
  if (session.document === doc)
@@ -1309,7 +1647,7 @@ function buildVersionPreview(deps) {
1309
1647
  }
1310
1648
  hideBar();
1311
1649
  },
1312
- };
1650
+ });
1313
1651
  const render = () => {
1314
1652
  const doc = session.document;
1315
1653
  const docId = doc?.id ?? null;
@@ -1322,7 +1660,7 @@ function buildVersionPreview(deps) {
1322
1660
  browsing = false;
1323
1661
  dismissedAt = undefined;
1324
1662
  }
1325
- // A live checkout (`doc.version !== null`) starts / keeps the browsing
1663
+ // A live checkout (`doc.previewVersion !== null`) starts / keeps the browsing
1326
1664
  // session. Other read-only states (a view-only share link, a still-loading
1327
1665
  // doc) are not this bar's concern, and `doc.id` is null only on the offline
1328
1666
  // / test path the embed can't serve.
@@ -1331,8 +1669,8 @@ function buildVersionPreview(deps) {
1331
1669
  // document is on and outranks the dismissal.
1332
1670
  if (doc != null &&
1333
1671
  docId !== null &&
1334
- (doc.version !== null || doc.versionBrowsing) &&
1335
- (doc.versionBrowsing || doc.version !== dismissedAt)) {
1672
+ (doc.previewVersion !== null || doc.versionBrowsing) &&
1673
+ (doc.versionBrowsing || doc.previewVersion !== dismissedAt)) {
1336
1674
  browsing = true;
1337
1675
  dismissedAt = undefined;
1338
1676
  // Mirror the open bar onto the document, which is what makes it
@@ -1343,15 +1681,15 @@ function buildVersionPreview(deps) {
1343
1681
  doc._setVersionBrowsing(true);
1344
1682
  }
1345
1683
  // The bar stays open for the whole session, including at the live head
1346
- // (`doc.version === null`, browsing still true), until `close`.
1684
+ // (`doc.previewVersion === null`, browsing still true), until `close`.
1347
1685
  if (doc != null && docId !== null && browsing) {
1348
1686
  root.classList.add('b-version-on');
1349
1687
  if (!frame)
1350
- frame = createVersionBarFrame(root, frameDeps);
1688
+ frame = createVersionBarFrame(root, frameDeps());
1351
1689
  frame.update({
1352
1690
  docId,
1353
1691
  branch: doc.branchId ?? undefined,
1354
- version: doc.version,
1692
+ version: doc.previewVersion,
1355
1693
  branchName: doc._currentBranchName() ?? undefined,
1356
1694
  pinned: doc._isVersionPinned,
1357
1695
  });
@@ -1393,7 +1731,7 @@ export function createDocumentTitle(container, deps) {
1393
1731
  }
1394
1732
  // The toolbar-surface commands (undo / redo) as prominent buttons.
1395
1733
  export function createUndoRedo(container, deps) {
1396
- return mountPiece(container, buildUndoRedo(deps));
1734
+ return mountPiece(container, buildUndoRedo(deps, 'standalone'));
1397
1735
  }
1398
1736
  // The presence roster (coloured initials of who's on the active doc).
1399
1737
  // Receiver half only; the app still publishes its own state.
@@ -1430,9 +1768,13 @@ function buildBarShell() {
1430
1768
  // row-gap, leaving asymmetric padding. "Empty" = every child is blank (no
1431
1769
  // text, no element children), so an empty save-indicator span or a
1432
1770
  // command-less undo/redo piece both count.
1771
+ // `b-app-tools` marks a strip holding something of the app's beside
1772
+ // undo / redo, which the narrow bar shows on a row of its own.
1433
1773
  function updateToolsVisibility() {
1434
- const allBlank = Array.from(tools.children).every((c) => (c.textContent ?? '').trim() === '' && c.childElementCount === 0);
1435
- tools.style.display = allBlank ? 'none' : '';
1774
+ const blank = (c) => (c.textContent ?? '').trim() === '' && c.childElementCount === 0;
1775
+ const children = Array.from(tools.children);
1776
+ tools.style.display = children.every(blank) ? 'none' : '';
1777
+ bar.classList.toggle('b-app-tools', children.some((c) => !c.classList.contains('b-undo-full') && !blank(c)));
1436
1778
  }
1437
1779
  // The app (and the undo/redo subscription) mutates `tools` after mount, so
1438
1780
  // re-evaluate visibility on any change.
@@ -1470,10 +1812,15 @@ export function createSessionToolbar(container, deps, options = {}) {
1470
1812
  shell.start.append(drivePiece.root);
1471
1813
  pieces.push(drivePiece);
1472
1814
  }
1473
- // Middle: SDK toolbar-surface commands (undo/redo) + the app's slot.
1815
+ // Middle: SDK toolbar-surface commands (undo/redo) + the app's slot. On a
1816
+ // narrow screen the middle wraps to a row of its own, so Undo and Redo show
1817
+ // there instead as icons in the right cluster (CSS swaps the two), and the
1818
+ // middle row only appears for the app's own tools.
1474
1819
  const undoRedo = buildUndoRedo(deps);
1475
1820
  shell.tools.append(undoRedo.root);
1476
- pieces.push(undoRedo);
1821
+ const compactUndo = buildUndoRedo(deps, 'compact');
1822
+ shell.end.append(compactUndo.root);
1823
+ pieces.push(undoRedo, compactUndo);
1477
1824
  // Right cluster: presence roster + account menu.
1478
1825
  if (showPresence) {
1479
1826
  const rosterPiece = buildPresenceRoster(deps);
@@ -1485,12 +1832,14 @@ export function createSessionToolbar(container, deps, options = {}) {
1485
1832
  shell.end.append(accountPiece.root);
1486
1833
  pieces.push(accountPiece);
1487
1834
  }
1488
- // The historical-version preview bar sits below the bar as a full-width
1489
- // strip: mounting the toolbar is all an app does to get correct version-
1490
- // preview UX, so it never hand-rolls one.
1835
+ // Two full-width strips sit below the bar: the not-signed-in notice on a
1836
+ // document created without an account, and the historical-version preview
1837
+ // bar. Mounting the toolbar is all an app does to get both, so it never
1838
+ // hand-rolls either.
1839
+ const guestPiece = buildNotSignedIn(deps);
1491
1840
  const versionPiece = buildVersionPreview(deps);
1492
- pieces.push(versionPiece);
1493
- container.replaceChildren(shell.bar, versionPiece.root);
1841
+ pieces.push(guestPiece, versionPiece);
1842
+ container.replaceChildren(shell.bar, guestPiece.root, versionPiece.root);
1494
1843
  return {
1495
1844
  tools: shell.tools,
1496
1845
  destroy() {
@@ -1507,16 +1856,19 @@ export function createSessionToolbar(container, deps, options = {}) {
1507
1856
  // never wraps when `tools` does.
1508
1857
  export function createSessionHeader(container, deps) {
1509
1858
  const shell = buildBarShell();
1510
- // A header shell built with session access includes the historical-version
1511
- // preview bar below the bar, so a composable-chrome app gets the same
1512
- // correct preview UX the full toolbar does without a separate mount.
1859
+ // A header shell built with session access includes the two strips below
1860
+ // the bar (the not-signed-in notice and the historical-version preview
1861
+ // bar), so a composable-chrome app gets what the full toolbar renders
1862
+ // there without a separate mount.
1863
+ const guestPiece = deps ? buildNotSignedIn(deps) : null;
1513
1864
  const versionPiece = deps ? buildVersionPreview(deps) : null;
1514
- container.replaceChildren(shell.bar, ...(versionPiece ? [versionPiece.root] : []));
1865
+ container.replaceChildren(shell.bar, ...(guestPiece ? [guestPiece.root] : []), ...(versionPiece ? [versionPiece.root] : []));
1515
1866
  return {
1516
1867
  start: shell.start,
1517
1868
  tools: shell.tools,
1518
1869
  end: shell.end,
1519
1870
  destroy() {
1871
+ guestPiece?.destroy();
1520
1872
  versionPiece?.destroy();
1521
1873
  shell.destroy();
1522
1874
  container.replaceChildren();