cursedbelt 4.6.0 → 5.0.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 (144) hide show
  1. package/README.md +36 -0
  2. package/dist/react/chat/index.d.ts +1 -0
  3. package/dist/react/chat/index.d.ts.map +1 -1
  4. package/dist/react/chat/index.js +1 -0
  5. package/dist/react/chat/index.js.map +1 -1
  6. package/dist/react/chat/usePresence.d.ts +75 -0
  7. package/dist/react/chat/usePresence.d.ts.map +1 -0
  8. package/dist/react/chat/usePresence.js +109 -0
  9. package/dist/react/chat/usePresence.js.map +1 -0
  10. package/dist/react/components/SecretInput.d.ts +7 -2
  11. package/dist/react/components/SecretInput.d.ts.map +1 -1
  12. package/dist/react/components/SecretInput.js +5 -2
  13. package/dist/react/components/SecretInput.js.map +1 -1
  14. package/dist/react/components/TooltipImpl.d.ts.map +1 -1
  15. package/dist/react/components/TooltipImpl.js +52 -3
  16. package/dist/react/components/TooltipImpl.js.map +1 -1
  17. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  18. package/dist/react/file-tree/FileTree.js +19 -1
  19. package/dist/react/file-tree/FileTree.js.map +1 -1
  20. package/dist/react/index.d.ts +1 -0
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +1 -0
  23. package/dist/react/index.js.map +1 -1
  24. package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
  25. package/dist/react/master-lock/MasterLockDialog.js +2 -1
  26. package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
  27. package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
  28. package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
  29. package/dist/react/master-lock/MasterLockProvider.js +14 -20
  30. package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
  31. package/dist/react/media/MediaUploadControl.d.ts +3 -7
  32. package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
  33. package/dist/react/media/MediaUploadControl.js +4 -13
  34. package/dist/react/media/MediaUploadControl.js.map +1 -1
  35. package/dist/react/media/fileUploadClient.d.ts +0 -8
  36. package/dist/react/media/fileUploadClient.d.ts.map +1 -1
  37. package/dist/react/media/fileUploadClient.js +0 -8
  38. package/dist/react/media/fileUploadClient.js.map +1 -1
  39. package/dist/react/media/hlsSource.d.ts.map +1 -1
  40. package/dist/react/media/hlsSource.js +4 -2
  41. package/dist/react/media/hlsSource.js.map +1 -1
  42. package/dist/react/media/index.d.ts +0 -4
  43. package/dist/react/media/index.d.ts.map +1 -1
  44. package/dist/react/media/index.js +0 -4
  45. package/dist/react/media/index.js.map +1 -1
  46. package/dist/react/sessionAuth.d.ts +1 -2
  47. package/dist/react/sessionAuth.d.ts.map +1 -1
  48. package/dist/react/sessionAuth.js +2 -3
  49. package/dist/react/sessionAuth.js.map +1 -1
  50. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  51. package/dist/styles-areas/activity-bar.css +1 -1
  52. package/dist/styles-areas/auth.css +1 -1
  53. package/dist/styles-areas/charts.css +1 -1
  54. package/dist/styles-areas/chat.css +1 -1
  55. package/dist/styles-areas/core.css +1 -1
  56. package/dist/styles-areas/data-table.css +1 -1
  57. package/dist/styles-areas/disk-usage.css +1 -1
  58. package/dist/styles-areas/fields.css +1 -1
  59. package/dist/styles-areas/file-tree.css +1 -1
  60. package/dist/styles-areas/master-detail.css +1 -1
  61. package/dist/styles-areas/master-lock.css +108 -2
  62. package/dist/styles-areas/media-gallery.css +1 -1
  63. package/dist/styles-areas/media.css +1 -17
  64. package/dist/styles-areas/nav.css +1 -1
  65. package/dist/styles-areas/notifications.css +1 -1
  66. package/dist/styles-areas/spreadsheet.css +1 -1
  67. package/dist/styles-areas/stats.css +1 -1
  68. package/dist/styles-areas/wizard.css +1 -1
  69. package/dist/styles-areas/workbook-viewer.css +1 -1
  70. package/dist/styles-utilities.css +0 -16
  71. package/dist/testing/_noBun.d.ts +2 -0
  72. package/dist/testing/_noBun.d.ts.map +1 -0
  73. package/dist/testing/_noBun.js +10 -0
  74. package/dist/testing/_noBun.js.map +1 -0
  75. package/package.json +42 -19
  76. package/scripts/checkAreaStyles.spec.ts +71 -0
  77. package/scripts/checkAreaStyles.ts +150 -0
  78. package/scripts/guardrailsEnforce.spec.ts +43 -0
  79. package/scripts/guardrailsEnforce.ts +46 -1
  80. package/scripts/styleAreas.ts +1 -2
  81. package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
  82. package/src/bundlerOnlyEntries.spec.ts +101 -0
  83. package/src/declaredImports.spec.ts +4 -0
  84. package/src/optionalPeersTable.spec.ts +127 -0
  85. package/src/react/chat/index.ts +8 -0
  86. package/src/react/chat/usePresence.spec.tsx +138 -0
  87. package/src/react/chat/usePresence.ts +185 -0
  88. package/src/react/components/SecretInput.tsx +12 -1
  89. package/src/react/components/TooltipImpl.tsx +54 -5
  90. package/src/react/file-tree/FileTree.tsx +19 -1
  91. package/src/react/file-tree/fileTreeThumbs.spec.tsx +90 -0
  92. package/src/react/index.ts +8 -0
  93. package/src/react/master-lock/MasterLockDialog.tsx +13 -9
  94. package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
  95. package/src/react/master-lock/MasterLockProvider.tsx +16 -19
  96. package/src/react/media/MediaUploadControl.tsx +8 -29
  97. package/src/react/media/fileUploadClient.ts +7 -17
  98. package/src/react/media/hlsSource.ts +4 -1
  99. package/src/react/media/index.ts +0 -16
  100. package/src/react/sessionAuth.ts +2 -3
  101. package/src/scriptsResolve.spec.ts +66 -0
  102. package/src/styles-areas/activity-bar.css +1 -1
  103. package/src/styles-areas/auth.css +1 -1
  104. package/src/styles-areas/charts.css +1 -1
  105. package/src/styles-areas/chat.css +1 -1
  106. package/src/styles-areas/core.css +1 -1
  107. package/src/styles-areas/data-table.css +1 -1
  108. package/src/styles-areas/disk-usage.css +1 -1
  109. package/src/styles-areas/fields.css +1 -1
  110. package/src/styles-areas/file-tree.css +1 -1
  111. package/src/styles-areas/master-detail.css +1 -1
  112. package/src/styles-areas/master-lock.css +108 -2
  113. package/src/styles-areas/media-gallery.css +1 -1
  114. package/src/styles-areas/media.css +1 -17
  115. package/src/styles-areas/nav.css +1 -1
  116. package/src/styles-areas/notifications.css +1 -1
  117. package/src/styles-areas/spreadsheet.css +1 -1
  118. package/src/styles-areas/stats.css +1 -1
  119. package/src/styles-areas/wizard.css +1 -1
  120. package/src/styles-areas/workbook-viewer.css +1 -1
  121. package/src/styles-utilities.css +0 -16
  122. package/src/testing/_noBun.ts +12 -0
  123. package/src/verifyGraph.spec.ts +11 -0
  124. package/dist/react/media/PrivateIndexView.d.ts +0 -6
  125. package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
  126. package/dist/react/media/PrivateIndexView.js +0 -44
  127. package/dist/react/media/PrivateIndexView.js.map +0 -1
  128. package/dist/react/media/PrivateModeBadge.d.ts +0 -17
  129. package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
  130. package/dist/react/media/PrivateModeBadge.js +0 -24
  131. package/dist/react/media/PrivateModeBadge.js.map +0 -1
  132. package/dist/react/media/privateClient.d.ts +0 -34
  133. package/dist/react/media/privateClient.d.ts.map +0 -1
  134. package/dist/react/media/privateClient.js +0 -36
  135. package/dist/react/media/privateClient.js.map +0 -1
  136. package/dist/react/media/usePrivateUnlock.d.ts +0 -25
  137. package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
  138. package/dist/react/media/usePrivateUnlock.js +0 -68
  139. package/dist/react/media/usePrivateUnlock.js.map +0 -1
  140. package/src/react/media/PrivateIndexView.tsx +0 -113
  141. package/src/react/media/PrivateModeBadge.tsx +0 -50
  142. package/src/react/media/privateClient.spec.ts +0 -113
  143. package/src/react/media/privateClient.ts +0 -82
  144. package/src/react/media/usePrivateUnlock.ts +0 -132
@@ -44,6 +44,10 @@ export default function TooltipImpl({
44
44
  const resolvedSide = placement ?? side ?? 'top';
45
45
  const triggerRef = useRef<HTMLElement | null>(null);
46
46
  const wasFocusedRef = useRef(false);
47
+ /** The node a TOUCH disclosure's taps land on — the ⓘ, or the `tap` branch's span. */
48
+ const touchTriggerRef = useRef<HTMLButtonElement | null>(null);
49
+ /** True while one of that trigger's own events is being dispatched — see `onTouchOpenChange`. */
50
+ const inTriggerEventRef = useRef(false);
47
51
 
48
52
  // Replay the arming gesture. Radix opens on `pointermove` over the trigger and on
49
53
  // focus; both already happened before this module existed, so re-dispatch one on
@@ -215,7 +219,16 @@ export default function TooltipImpl({
215
219
  sideOffset={6}
216
220
  collisionPadding={FLOATING_COLLISION_PADDING}
217
221
  style={maxWidth != null ? { maxWidth } : undefined}
218
- onPointerDownOutside={() => setOpen(false)}
222
+ onPointerDownOutside={(e) => {
223
+ // A touch disclosure's OWN trigger is outside the bubble, but a tap on it is the
224
+ // toggle's business (it closes an open bubble on its click). Letting the layer
225
+ // dismiss here as well would close on this pointerdown and re-open on the click.
226
+ if (!hasHover && touchTriggerRef.current?.contains(e.target as Node)) {
227
+ e.preventDefault();
228
+ return;
229
+ }
230
+ setOpen(false);
231
+ }}
219
232
  className={cn(
220
233
  // NOTE: the bubble is a transient hint and must NEVER be a click target —
221
234
  // a focus-opened tooltip (e.g. a prompt auto-focusing its safe default
@@ -276,6 +289,42 @@ export default function TooltipImpl({
276
289
 
277
290
  const toggle = () => setOpen((o) => !o);
278
291
 
292
+ /*
293
+ * 🔴 On touch, the TAP owns `open`, and Radix's trigger handlers are told nothing they say.
294
+ *
295
+ * Tasks 2082 + 2093, measured in a Pixel 7 context on station's Console: the first tap on
296
+ * every ⓘ did nothing and the second opened it. A real tap fires `pointerdown → pointerup →
297
+ * focus → click`. Radix's Trigger clears its pointer-down flag on `pointerup`, so the
298
+ * `focus` that follows reads as KEYBOARD focus and opens; then this branch's click `toggle`
299
+ * flips that fresh `true` back to `false`. (Radix also composes `onClose` onto every click
300
+ * and every `pointerdown` on an open trigger, which is the half the tasks first suspected.)
301
+ *
302
+ * So while one of the trigger's own events is being dispatched, `onOpenChange` from Radix
303
+ * is ignored — the flag is set by the handlers below, which `composeEventHandlers` runs
304
+ * BEFORE Radix's, and cleared in a microtask, which runs once the whole dispatch returns.
305
+ * Nothing is `preventDefault`ed, so a `mobile="tap"` trigger that is a link still
306
+ * navigates. Every OTHER close Radix makes still lands: Escape, scroll, and another
307
+ * tooltip opening. `e2e/tooltip-tap.spec.ts` is the proof, in the `touch` project — it
308
+ * cannot be a happy-dom case, because happy-dom fires whatever order a spec writes.
309
+ */
310
+ const markTriggerEvent = () => {
311
+ inTriggerEventRef.current = true;
312
+ queueMicrotask(() => {
313
+ inTriggerEventRef.current = false;
314
+ });
315
+ };
316
+ const onTouchOpenChange = (next: boolean) => {
317
+ if (inTriggerEventRef.current) return;
318
+ setOpen(next);
319
+ };
320
+ const touchTriggerProps = {
321
+ ref: touchTriggerRef,
322
+ onPointerDown: markTriggerEvent,
323
+ onFocus: markTriggerEvent,
324
+ onBlur: markTriggerEvent,
325
+ onClick: markTriggerEvent,
326
+ };
327
+
279
328
  // `icon`: render the trigger as-is plus a tappable ⓘ disclosure that owns the
280
329
  // tooltip (so a tap on the trigger still does its own action).
281
330
  if (mobile === 'icon') {
@@ -287,8 +336,8 @@ export default function TooltipImpl({
287
336
  accidental hover to protect against on a touch device — see
288
337
  `lib/hoverIntent.ts`, "delay the accident, never the request". */}
289
338
  <RTooltip.Provider delayDuration={HOVER_HINT_INSTANT_MS}>
290
- <RTooltip.Root open={open} onOpenChange={setOpen}>
291
- <RTooltip.Trigger asChild>
339
+ <RTooltip.Root open={open} onOpenChange={onTouchOpenChange}>
340
+ <RTooltip.Trigger asChild {...touchTriggerProps}>
292
341
  <button
293
342
  // guardrails-ignore no-raw-action-button: this IS the Tooltip primitive's
294
343
  // own mobile disclosure trigger — Button/IconButton wrap every button in a
@@ -321,8 +370,8 @@ export default function TooltipImpl({
321
370
  // as the `icon` branch above — the tap IS the request.
322
371
  return (
323
372
  <RTooltip.Provider delayDuration={HOVER_HINT_INSTANT_MS}>
324
- <RTooltip.Root open={open} onOpenChange={setOpen}>
325
- <RTooltip.Trigger asChild>
373
+ <RTooltip.Root open={open} onOpenChange={onTouchOpenChange}>
374
+ <RTooltip.Trigger asChild {...touchTriggerProps}>
326
375
  {/* On touch, tapping the trigger toggles the tooltip. Radix's Trigger
327
376
  (asChild) merges the real keyboard + ARIA interaction onto this element;
328
377
  the bare onClick here is only the tap affordance. */}
@@ -189,6 +189,7 @@ import {
189
189
  toggleSelected,
190
190
  topmostSelected,
191
191
  } from './fileTreeSelection.js';
192
+ import { useInView } from '../media/previewGate.js';
192
193
  import './fileTree.css';
193
194
 
194
195
  /** How far a press travels before it stops being a click and becomes a drag. */
@@ -597,6 +598,16 @@ function DefaultIcon({ node, open }: { node: FileTreeNode; open: boolean }): Rea
597
598
  *
598
599
  * `loading='lazy'` and `decoding='async'` because the consumer hands over a URL per row and a
599
600
  * 2,000-row tree must not open 2,000 connections to draw the twelve rows on screen.
601
+ *
602
+ * 🔴 …and `src` is withheld until the row has actually been ON SCREEN, because `lazy` alone
603
+ * does not bound it. A browser's lazy threshold is thousands of pixels past the viewport, and
604
+ * rows are ~24 px: measured 2026-09-22 in `apps/collections`' browser check, a 240-row folder
605
+ * in the rail had **174** thumbnail loads open at once under `loading='lazy'` — the whole of
606
+ * the owner's "I could not close the dev tools" peak, from sixteen-pixel pictures. `useInView`
607
+ * reports true only while the row is visible inside its scrolling ancestors; the first time it
608
+ * does, the picture is latched and the row stops being observed. The `<img>` itself stays in
609
+ * place meanwhile, so the row keeps its one glyph box — an off-screen row is blank, which
610
+ * nobody is looking at. No reporting observer (jsdom, happy-dom) means visible at once.
600
611
  */
601
612
  function RowGlyph({
602
613
  node,
@@ -610,12 +621,19 @@ function RowGlyph({
610
621
  const [failed, setFailed] = useState<string | null>(null);
611
622
  const icon = renderIcon ? renderIcon(node) : <DefaultIcon node={node} open={open} />;
612
623
  const src = node.thumbnailUrl;
624
+ const ref = useRef<HTMLImageElement>(null);
625
+ const [seen, setSeen] = useState(false);
626
+ const inView = useInView(ref, Boolean(src) && failed !== src && !seen);
627
+ useEffect(() => {
628
+ if (inView) setSeen(true);
629
+ }, [inView]);
613
630
  if (!src || failed === src) return icon;
614
631
  return (
615
632
  <img
633
+ ref={ref}
616
634
  key={src}
617
635
  className='cbft-thumb'
618
- src={src}
636
+ src={seen || inView ? src : undefined}
619
637
  alt=''
620
638
  aria-hidden='true'
621
639
  loading='lazy'
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The rail's row thumbnails load only once their row has been ON SCREEN — `RowGlyph` in
3
+ * `FileTree.tsx`. `loading='lazy'` alone let a 240-row folder open 174 thumbnail loads at once
4
+ * (measured 2026-09-22 in `apps/collections`' browser check), because a browser's lazy threshold
5
+ * is thousands of pixels and a row is ~24.
6
+ *
7
+ * happy-dom's `IntersectionObserver` never reports, so this installs one it can drive, the way
8
+ * `media/previewGate.spec.tsx` does.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import { act, cleanup, render } from '@testing-library/react';
12
+ import { FileTree } from './FileTree.js';
13
+
14
+ type Callback = (entries: Array<{ target: Element; isIntersecting: boolean }>) => void;
15
+ let observers: FakeObserver[] = [];
16
+
17
+ class FakeObserver {
18
+ targets = new Set<Element>();
19
+ constructor(private readonly callback: Callback) {
20
+ observers.push(this);
21
+ }
22
+ observe(el: Element) {
23
+ this.targets.add(el);
24
+ }
25
+ unobserve(el: Element) {
26
+ this.targets.delete(el);
27
+ }
28
+ disconnect() {
29
+ this.targets.clear();
30
+ }
31
+ takeRecords() {
32
+ return [];
33
+ }
34
+ get thresholds(): number[] {
35
+ return [0];
36
+ }
37
+ fire(el: Element, isIntersecting: boolean) {
38
+ this.callback([{ target: el, isIntersecting }]);
39
+ }
40
+ }
41
+
42
+ const g = globalThis as { IntersectionObserver?: unknown };
43
+ const original = g.IntersectionObserver;
44
+
45
+ beforeEach(() => {
46
+ observers = [];
47
+ g.IntersectionObserver = class extends FakeObserver {};
48
+ });
49
+
50
+ afterEach(() => {
51
+ cleanup();
52
+ if (original === undefined) delete g.IntersectionObserver;
53
+ else g.IntersectionObserver = original;
54
+ });
55
+
56
+ const rows = Array.from({ length: 3 }, (_, i) => ({
57
+ id: `p${i}`,
58
+ name: `photo${i}.jpg`,
59
+ kind: 'file' as const,
60
+ thumbnailUrl: `/t/p${i}.jpg`,
61
+ }));
62
+
63
+ describe('a row thumbnail waits for its row to be seen', () => {
64
+ test('🔴 an off-screen row asks for nothing; the first sight loads it, and it stays loaded', () => {
65
+ const { container } = render(<FileTree nodes={rows} selectedId={null} onSelect={() => undefined} />);
66
+ const imgs = [...container.querySelectorAll('img.cbft-thumb')] as HTMLImageElement[];
67
+ expect(imgs).toHaveLength(3);
68
+ // Nothing reported yet: no row has a src, so no request has gone out.
69
+ expect(imgs.map((img) => img.getAttribute('src'))).toEqual([null, null, null]);
70
+
71
+ const observer = observers.find((o) => o.targets.size > 0) as FakeObserver;
72
+ const [first] = imgs;
73
+ act(() => observer.fire(first as Element, true));
74
+ expect(first?.getAttribute('src')).toBe('/t/p0.jpg');
75
+ expect(imgs[1]?.getAttribute('src')).toBeNull();
76
+
77
+ // Scrolled away again: the picture is kept, and the row is no longer observed.
78
+ act(() => observer.fire(first as Element, false));
79
+ expect(first?.getAttribute('src')).toBe('/t/p0.jpg');
80
+ expect(observer.targets.has(first as Element)).toBe(false);
81
+ expect(observer.targets.size).toBe(2);
82
+ });
83
+
84
+ test('with no reporting observer every thumbnail loads at once — the old behavior, never none', () => {
85
+ delete g.IntersectionObserver;
86
+ const { container } = render(<FileTree nodes={rows} selectedId={null} onSelect={() => undefined} />);
87
+ const srcs = [...container.querySelectorAll('img.cbft-thumb')].map((img) => img.getAttribute('src'));
88
+ expect(srcs).toEqual(['/t/p0.jpg', '/t/p1.jpg', '/t/p2.jpg']);
89
+ });
90
+ });
@@ -47,6 +47,14 @@ export { MessageScroller, type MessageScrollerProps } from './chat/MessageScroll
47
47
  export { MessageThread, type MessageThreadProps } from './chat/MessageThread.js';
48
48
  export { PresenceRoster, type PresenceRosterProps } from './chat/PresenceRoster.js';
49
49
  export { StatusMarker, type StatusMarkerProps } from './chat/StatusMarker.js';
50
+ export {
51
+ PRESENCE_RETRY_MS,
52
+ type PresenceEntryLike,
53
+ type PresenceSubscription,
54
+ presenceUsers,
55
+ type UsePresenceOptions,
56
+ usePresence,
57
+ } from './chat/usePresence.js';
50
58
  export { Markdown, type MarkdownProps } from './markdown/Markdown.js';
51
59
  export type {
52
60
  ChatAttachment,
@@ -28,6 +28,7 @@ import { Button } from "../components/Button.js";
28
28
  import { FieldLabel } from "../components/FieldLabel.js";
29
29
  import { Input } from "../components/Input.js";
30
30
  import { Modal } from "../components/Modal.js";
31
+ import { SecretInput } from "../components/SecretInput.js";
31
32
 
32
33
  export interface MasterLockDialogProps {
33
34
  open: boolean;
@@ -201,39 +202,42 @@ export function MasterLockDialog({
201
202
  <label htmlFor="ml-current">
202
203
  <FieldLabel>Current master password</FieldLabel>
203
204
  </label>
204
- <Input
205
+ <SecretInput
205
206
  id="ml-current"
206
- type="password"
207
+ label="current master password"
207
208
  autoComplete="current-password"
209
+ showCopy={false}
208
210
  value={current}
209
211
  disabled={busy}
210
- onChange={(event) => setCurrent(event.target.value)}
212
+ onChange={setCurrent}
211
213
  />
212
214
  </div>
213
215
  <div className="flex flex-col gap-1.5">
214
216
  <label htmlFor="ml-next">
215
217
  <FieldLabel>New master password</FieldLabel>
216
218
  </label>
217
- <Input
219
+ <SecretInput
218
220
  id="ml-next"
219
- type="password"
221
+ label="new master password"
220
222
  autoComplete="new-password"
223
+ showCopy={false}
221
224
  value={next}
222
225
  disabled={busy}
223
- onChange={(event) => setNext(event.target.value)}
226
+ onChange={setNext}
224
227
  />
225
228
  </div>
226
229
  <div className="flex flex-col gap-1.5">
227
230
  <label htmlFor="ml-confirm">
228
231
  <FieldLabel>Repeat the new one</FieldLabel>
229
232
  </label>
230
- <Input
233
+ <SecretInput
231
234
  id="ml-confirm"
232
- type="password"
235
+ label="repeated new master password"
233
236
  autoComplete="new-password"
237
+ showCopy={false}
234
238
  value={confirm}
235
239
  disabled={busy}
236
- onChange={(event) => setConfirm(event.target.value)}
240
+ onChange={setConfirm}
237
241
  onEnter={() => void changePassword()}
238
242
  />
239
243
  </div>
@@ -126,3 +126,20 @@ describe("🔴 the rotation never mints a KDF", () => {
126
126
  expect(code).toContain("kdf: MasterLockKdfParams;");
127
127
  });
128
128
  });
129
+
130
+ describe("the account-menu entries (task 106)", () => {
131
+ test("🔴 'Master passwords…' is a LINK to the accounts page, from the wire, not a string here", async () => {
132
+ const { MasterLockMenuItems } = await import("./MasterLockProvider.js");
133
+ const { findByText, getByText } = render(
134
+ <MasterLockProvider onLocked={() => {}}>
135
+ <MasterLockMenuItems itemClassName="sam-item" />
136
+ </MasterLockProvider>,
137
+ );
138
+ const link = await findByText("Master passwords…");
139
+ expect(link.tagName).toBe("A");
140
+ expect(link.getAttribute("href")).toBe(MASTER_LOCK_PATHS.accounts);
141
+ expect(link.className).toBe("sam-item");
142
+ // The idle timeout lives only in the dialog, so its entry stays.
143
+ expect(getByText("Lock settings…").tagName).toBe("BUTTON");
144
+ });
145
+ });
@@ -237,31 +237,25 @@ export interface MasterLockMenuItemsProps {
237
237
  }
238
238
 
239
239
  /**
240
- * The two entries for the account menu — *"a new option in the user dropdown … which is
240
+ * The entries for the account menu — *"a new option in the user dropdown … which is
241
241
  * located near the sign out and switch user options"*. The owner chose that placement
242
242
  * deliberately: *"this placement will help make sure it isn't confused with the private
243
243
  * collections lock or other locks like that."*
244
244
  *
245
245
  * Renders nothing when no master password is configured for the app.
246
246
  *
247
- * ── 🔴 The second entry is a LINK now, not a dialog (2026-09-17) ─────────────────────────
248
- * It used to call {@link MasterLockControl.openSettings}, and that dialog minted a FRESH `kdf`
249
- * salt on every rotation — the shape the wire had before accounts existed. Every account on an
250
- * app now derives under ONE shared descriptor, so honouring that salt would store a verifier
251
- * hash nothing could ever match: the owner sets a new password, the rotation logs him out, and
252
- * neither the old nor the new one opens the app. `cursedbelt-server`'s guard therefore answers
253
- * that shape `409 {"error":"stale-client"}` — deliberately distinguishable from a wrong
254
- * password, because it describes a broken CLIENT. So the entry did not merely look wrong; it
255
- * refused, and the owner could lock a site and enroll a first password but could not CHANGE one.
247
+ * ── The second entry is a LINK to the accounts page (task 106, 2026-09-23) ─────────────────
248
+ * `cursedbelt-server` serves `GET /__lock/accounts` from the same guard that serves the lock
249
+ * page: server-rendered, listing the accounts and adding, renaming and rotating them. So the
250
+ * entry is an `<a href>`, not a button — the target is a same-origin page, so there is nothing
251
+ * for this bundle to open and nothing for it to keep in step — and the path is
252
+ * `MASTER_LOCK_PATHS.accounts` from `cursedbelt-core`'s wire, never a string here, because a
253
+ * second copy of the wire in the design system is what `wire.ts`'s header exists to prevent.
256
254
  *
257
- * `cursedbelt-server` serves a richer replacement at `GET /__lock/accounts`, from the same guard
258
- * that serves the lock page: server-rendered, content-negotiated, listing the accounts and
259
- * adding/renaming them as well as rotating. This package does NOT link to it yet — the path
260
- * constant lives in `cursedbelt-core@2`'s wire and this package is still on `^1.1.0`, so the
261
- * link is a task, not a one-liner (`tasks/Not_Started`, filed 2026-09-17). What IS fixed here
262
- * is the dialog itself: `MasterLockDialog` no longer mints a salt, so this entry works again
263
- * rather than 409ing. `apps/collections` links to the page directly from its own rail in the
264
- * meantime, and says why in a comment there.
255
+ * The THIRD entry keeps the dialog, because the dialog also owns the idle timeout, which the
256
+ * accounts page does not. (History: until 2026-09-17 the dialog minted a fresh `kdf` salt on
257
+ * every rotation and the server refused it `409 stale-client`; it derives under the app's
258
+ * existing descriptor now, so both surfaces rotate correctly.)
265
259
  */
266
260
  export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItemsProps) {
267
261
  const lock = useMasterLock();
@@ -283,6 +277,9 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
283
277
  >
284
278
  🔒 Lock this site
285
279
  </button>
280
+ <a href={MASTER_LOCK_PATHS.accounts} className={cls} onClick={() => close?.()}>
281
+ Master passwords…
282
+ </a>
286
283
  <button
287
284
  // guardrails-ignore no-raw-action-button: see the sibling above — it must match
288
285
  // the host menu's own rows, not this design system's buttons.
@@ -293,7 +290,7 @@ export function MasterLockMenuItems({ close, itemClassName }: MasterLockMenuItem
293
290
  lock.openSettings();
294
291
  }}
295
292
  >
296
- Master password…
293
+ Lock settings…
297
294
  </button>
298
295
  </>
299
296
  );
@@ -1,7 +1,6 @@
1
1
  import { Upload } from 'lucide-react';
2
2
  import { type ReactNode, useMemo, useRef, useState } from 'react';
3
3
  import { Button } from '../components/Button.js';
4
- import { Checkbox } from '../components/Checkbox.js';
5
4
  import { Input } from '../components/Input.js';
6
5
  import { cn } from '../lib/cn.js';
7
6
  import { createFileUploadClient } from './fileUploadClient.js';
@@ -12,8 +11,8 @@ import { createFileUploadClient } from './fileUploadClient.js';
12
11
  // no ENGINE-model consumer, but three shipping plugins render it
13
12
  // (`plugin-photo-gallery`, `plugin-video-gallery`, `plugin-media-gallery`), so
14
13
  // deleting it would break them. It speaks the legacy chunked `/_files` protocol
15
- // (`createFileUploadClient` + `createFilesStack`), including the tag list and the
16
- // X-Unlock-Token private mode that protocol supports.
14
+ // (`createFileUploadClient` + `createFilesStack`), including the tag list. (Its private
15
+ // mode went with the server's private-media module — task 108.)
17
16
  //
18
17
  // The ENGINE model uploads through `POST /api/asset/vault/upload` — raw bytes,
19
18
  // `x-cc-filename`, checksum computed while streaming, content-addressed dedupe —
@@ -25,19 +24,16 @@ import { createFileUploadClient } from './fileUploadClient.js';
25
24
  //
26
25
  // ── Media upload control ─────────────────────────────────────────────────────────
27
26
  // The one "⬆ Upload" affordance media galleries share: a disclosure button opening a
28
- // compact form — comma-separated tags, a Private toggle (only while an unlock token is
29
- // present), and a file picker — feeding the shared upload client (chunked-aware, private
30
- // via X-Unlock-Token). Consumers own only WHERE it sits and what happens after.
27
+ // compact form — comma-separated tags and a file picker — feeding the shared upload client
28
+ // (chunked-aware). Consumers own only WHERE it sits and what happens after.
31
29
 
32
30
  export interface MediaUploadControlProps {
33
31
  /** File-picker accept filter, e.g. "video/*" or "image/*". */
34
32
  accept: string;
35
33
  /** Mount prefix of the host's file routes (createFilesStack filesBasePath). */
36
34
  basePath: string;
37
- /** Unlock token while private mode is on — reveals the Private toggle. */
38
- privateToken?: string | null;
39
- /** After every successful batch: `wasPrivate` says which list to refresh. */
40
- onUploaded: (result: { wasPrivate: boolean }) => void;
35
+ /** After every successful batch. */
36
+ onUploaded: () => void;
41
37
  label?: ReactNode;
42
38
  className?: string;
43
39
  }
@@ -45,14 +41,12 @@ export interface MediaUploadControlProps {
45
41
  export function MediaUploadControl({
46
42
  accept,
47
43
  basePath,
48
- privateToken = null,
49
44
  onUploaded,
50
45
  label = 'Upload',
51
46
  className,
52
47
  }: MediaUploadControlProps) {
53
48
  const [open, setOpen] = useState(false);
54
49
  const [tags, setTags] = useState('');
55
- const [asPrivate, setAsPrivate] = useState(false);
56
50
  const [busy, setBusy] = useState(false);
57
51
  const [error, setError] = useState<string | null>(null);
58
52
  const inputRef = useRef<HTMLInputElement>(null);
@@ -62,19 +56,13 @@ export function MediaUploadControl({
62
56
  const uploadFiles = async (list: FileList) => {
63
57
  setBusy(true);
64
58
  setError(null);
65
- const wantPrivate = asPrivate && !!privateToken;
66
59
  const parsedTags = tags
67
60
  .split(',')
68
61
  .map((t) => t.trim())
69
62
  .filter(Boolean);
70
63
  try {
71
64
  for (const file of Array.from(list)) {
72
- const res = wantPrivate
73
- ? await client.uploadPrivate(file, {
74
- tags: parsedTags,
75
- unlockToken: privateToken,
76
- })
77
- : await client.uploadAuto(file, { tags: parsedTags });
65
+ const res = await client.uploadAuto(file, { tags: parsedTags });
78
66
  if (!res.ok) {
79
67
  setError(res.error);
80
68
  return;
@@ -82,8 +70,7 @@ export function MediaUploadControl({
82
70
  }
83
71
  setOpen(false);
84
72
  setTags('');
85
- setAsPrivate(false);
86
- onUploaded({ wasPrivate: wantPrivate });
73
+ onUploaded();
87
74
  } finally {
88
75
  setBusy(false);
89
76
  }
@@ -113,14 +100,6 @@ export function MediaUploadControl({
113
100
  className='max-w-[200px]'
114
101
  aria-label='Upload tags'
115
102
  />
116
- {privateToken ? (
117
- <Checkbox
118
- checked={asPrivate}
119
- onCheckedChange={(checked) => setAsPrivate(checked === true)}
120
- disabled={busy}
121
- label='Private'
122
- />
123
- ) : null}
124
103
  <Button
125
104
  size='sm'
126
105
  disabled={busy}
@@ -4,8 +4,13 @@ import { sessionAuthHeaders } from '../sessionAuth.js';
4
4
  // ── File-upload client (hook-free) ───────────────────────────────────────────────
5
5
  // The one client for mountFileRoutes' upload edge, shared by every consumer (the files
6
6
  // plugin, gallery upload buttons, the video editor's save-back) so the header protocol
7
- // (X-Filename / X-Tags / X-Unlock-Token) and the chunked-vs-streamed decision live in
8
- // exactly one place. Hook-free like privateClient: plugins hold no upload state.
7
+ // (X-Filename / X-Tags) and the chunked-vs-streamed decision live in exactly one place.
8
+ // Hook-free: plugins hold no upload state.
9
+ //
10
+ // 🔴 There is no private upload any more (task 108, 2026-09-23). `uploadPrivate` PUT to
11
+ // `/_files/upload-private`, a route `cursedbelt-server` deleted with its private-media module
12
+ // (task 00-urgent-24), so the method was a 404 waiting for a caller. It went, with the rest of
13
+ // the private-media client tier, rather than staying as a method that cannot succeed.
9
14
 
10
15
  /** The FileRecord subset consumers act on after an upload (full shape: server files/types). */
11
16
  export type UploadedFile = {
@@ -35,12 +40,6 @@ export interface FileUploadClientConfig {
35
40
  export interface FileUploadClient {
36
41
  /** Streamed single-PUT public upload. */
37
42
  upload(file: File | Blob, opts?: { name?: string; tags?: string[] }): Promise<UploadOutcome>;
38
- /** Private (encrypted) upload — pass the unlock token of the already-unlocked session, or a
39
- * password for a deniable set; neither ⇒ the standard server-key set. */
40
- uploadPrivate(
41
- file: File | Blob,
42
- opts?: { name?: string; tags?: string[]; unlockToken?: string | null; password?: string },
43
- ): Promise<UploadOutcome>;
44
43
  /** Overwrite an existing file's bytes in place (same id; tags/collections survive). */
45
44
  replace(
46
45
  fileId: string,
@@ -163,15 +162,6 @@ export function createFileUploadClient(cfg: FileUploadClientConfig = {}): FileUp
163
162
  return {
164
163
  upload,
165
164
 
166
- async uploadPrivate(file, opts) {
167
- const headers = commonHeaders(file, opts);
168
- if (opts?.unlockToken) headers['X-Unlock-Token'] = opts.unlockToken;
169
- else if (opts?.password) headers['X-Private-Password'] = opts.password;
170
- return outcome(
171
- await doFetch(`${base}/upload-private`, { method: 'PUT', headers, body: file }),
172
- );
173
- },
174
-
175
165
  async replace(fileId, file, opts) {
176
166
  return outcome(
177
167
  await doFetch(`${base}/replace/${fileId}`, {
@@ -339,7 +339,10 @@ export function attachHlsSource(
339
339
  // guidance: calling `recoverMediaError()` twice for the same stall recovers
340
340
  // nothing, and the audio-codec swap is what clears the case that keeps recurring.
341
341
  if (attempt === 0) hls.recoverMediaError();
342
- else hls.swapAudioCodec?.(), hls.recoverMediaError();
342
+ else {
343
+ hls.swapAudioCodec?.();
344
+ hls.recoverMediaError();
345
+ }
343
346
  return;
344
347
  }
345
348
  if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
@@ -145,15 +145,6 @@ export {
145
145
  probeMediaDurationFromUrl,
146
146
  useMediaDuration,
147
147
  } from './mediaProbe.js';
148
- export { PrivateIndexView } from './PrivateIndexView.js';
149
- export { PrivateModeBadge, type PrivateModeBadgeProps } from './PrivateModeBadge.js';
150
- export {
151
- type PrivateClientConfig,
152
- privateFileUrlWithToken,
153
- privateHlsUrlWithToken,
154
- revealPrivateWithPassword,
155
- revealPrivateWithToken,
156
- } from './privateClient.js';
157
148
  export type {
158
149
  ImageEditorMode,
159
150
  ImageEditorOperation,
@@ -168,13 +159,6 @@ export type {
168
159
  export { type UseAudioPeaksOptions, useAudioPeaks } from './useAudioPeaks.js';
169
160
  export { useIntersectionObserver } from './useIntersectionObserver.js';
170
161
  export { useObjectUrl } from './useObjectUrl.js';
171
- export {
172
- type PrivateIndexRow,
173
- type PrivateUnlock,
174
- type RevealedItem,
175
- type UsePrivateUnlockOptions,
176
- usePrivateUnlock,
177
- } from './usePrivateUnlock.js';
178
162
  // Attaching an adaptive ladder to a bare `<video>` — native HLS first, hls.js second, the
179
163
  // progressive remux always. Exported because three surfaces in the fleet drive a plain
180
164
  // element rather than Vidstack and each was carrying its own copy of the decision.
@@ -1,6 +1,6 @@
1
1
  // ── Session auth headers (hook-free) ────────────────────────────────────────
2
2
  // One source of truth for "turn the browser session into a Bearer header". Plugins
3
- // that call guarded endpoints (uploads, the private-media client, RPC over fetch)
3
+ // that call guarded endpoints (uploads, RPC over fetch)
4
4
  // all need the same header shape; before this they each re-rolled the cookie read.
5
5
  //
6
6
  // NOTE (genapp-34): a generated app's `session` cookie is now `HttpOnly`, so this
@@ -19,8 +19,7 @@
19
19
  *
20
20
  * Shaped as a plain function (not a hook) so it can be passed straight to helpers
21
21
  * that expect an `authHeaders: () => Record<string, string>` callback
22
- * (`revealPrivateWithToken`, `privateHlsUrlWithToken`, …) and spread into a
23
- * `fetch` init's headers.
22
+ * (`createFileUploadClient`'s, for one) and spread into a `fetch` init's headers.
24
23
  */
25
24
  export function sessionAuthHeaders(): Record<string, string> {
26
25
  const token =