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.
- package/README.md +36 -0
- package/dist/react/chat/index.d.ts +1 -0
- package/dist/react/chat/index.d.ts.map +1 -1
- package/dist/react/chat/index.js +1 -0
- package/dist/react/chat/index.js.map +1 -1
- package/dist/react/chat/usePresence.d.ts +75 -0
- package/dist/react/chat/usePresence.d.ts.map +1 -0
- package/dist/react/chat/usePresence.js +109 -0
- package/dist/react/chat/usePresence.js.map +1 -0
- package/dist/react/components/SecretInput.d.ts +7 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +5 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/TooltipImpl.d.ts.map +1 -1
- package/dist/react/components/TooltipImpl.js +52 -3
- package/dist/react/components/TooltipImpl.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +19 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +2 -1
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +14 -20
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/MediaUploadControl.d.ts +3 -7
- package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
- package/dist/react/media/MediaUploadControl.js +4 -13
- package/dist/react/media/MediaUploadControl.js.map +1 -1
- package/dist/react/media/fileUploadClient.d.ts +0 -8
- package/dist/react/media/fileUploadClient.d.ts.map +1 -1
- package/dist/react/media/fileUploadClient.js +0 -8
- package/dist/react/media/fileUploadClient.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +4 -2
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +0 -4
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +0 -4
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/sessionAuth.d.ts +1 -2
- package/dist/react/sessionAuth.d.ts.map +1 -1
- package/dist/react/sessionAuth.js +2 -3
- package/dist/react/sessionAuth.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/activity-bar.css +1 -1
- package/dist/styles-areas/auth.css +1 -1
- package/dist/styles-areas/charts.css +1 -1
- package/dist/styles-areas/chat.css +1 -1
- package/dist/styles-areas/core.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/disk-usage.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/file-tree.css +1 -1
- package/dist/styles-areas/master-detail.css +1 -1
- package/dist/styles-areas/master-lock.css +108 -2
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -17
- package/dist/styles-areas/nav.css +1 -1
- package/dist/styles-areas/notifications.css +1 -1
- package/dist/styles-areas/spreadsheet.css +1 -1
- package/dist/styles-areas/stats.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/dist/styles-areas/workbook-viewer.css +1 -1
- package/dist/styles-utilities.css +0 -16
- package/dist/testing/_noBun.d.ts +2 -0
- package/dist/testing/_noBun.d.ts.map +1 -0
- package/dist/testing/_noBun.js +10 -0
- package/dist/testing/_noBun.js.map +1 -0
- package/package.json +42 -19
- package/scripts/checkAreaStyles.spec.ts +71 -0
- package/scripts/checkAreaStyles.ts +150 -0
- package/scripts/guardrailsEnforce.spec.ts +43 -0
- package/scripts/guardrailsEnforce.ts +46 -1
- package/scripts/styleAreas.ts +1 -2
- package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
- package/src/bundlerOnlyEntries.spec.ts +101 -0
- package/src/declaredImports.spec.ts +4 -0
- package/src/optionalPeersTable.spec.ts +127 -0
- package/src/react/chat/index.ts +8 -0
- package/src/react/chat/usePresence.spec.tsx +138 -0
- package/src/react/chat/usePresence.ts +185 -0
- package/src/react/components/SecretInput.tsx +12 -1
- package/src/react/components/TooltipImpl.tsx +54 -5
- package/src/react/file-tree/FileTree.tsx +19 -1
- package/src/react/file-tree/fileTreeThumbs.spec.tsx +90 -0
- package/src/react/index.ts +8 -0
- package/src/react/master-lock/MasterLockDialog.tsx +13 -9
- package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
- package/src/react/master-lock/MasterLockProvider.tsx +16 -19
- package/src/react/media/MediaUploadControl.tsx +8 -29
- package/src/react/media/fileUploadClient.ts +7 -17
- package/src/react/media/hlsSource.ts +4 -1
- package/src/react/media/index.ts +0 -16
- package/src/react/sessionAuth.ts +2 -3
- package/src/scriptsResolve.spec.ts +66 -0
- package/src/styles-areas/activity-bar.css +1 -1
- package/src/styles-areas/auth.css +1 -1
- package/src/styles-areas/charts.css +1 -1
- package/src/styles-areas/chat.css +1 -1
- package/src/styles-areas/core.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/disk-usage.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/file-tree.css +1 -1
- package/src/styles-areas/master-detail.css +1 -1
- package/src/styles-areas/master-lock.css +108 -2
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -17
- package/src/styles-areas/nav.css +1 -1
- package/src/styles-areas/notifications.css +1 -1
- package/src/styles-areas/spreadsheet.css +1 -1
- package/src/styles-areas/stats.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/styles-areas/workbook-viewer.css +1 -1
- package/src/styles-utilities.css +0 -16
- package/src/testing/_noBun.ts +12 -0
- package/src/verifyGraph.spec.ts +11 -0
- package/dist/react/media/PrivateIndexView.d.ts +0 -6
- package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
- package/dist/react/media/PrivateIndexView.js +0 -44
- package/dist/react/media/PrivateIndexView.js.map +0 -1
- package/dist/react/media/PrivateModeBadge.d.ts +0 -17
- package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
- package/dist/react/media/PrivateModeBadge.js +0 -24
- package/dist/react/media/PrivateModeBadge.js.map +0 -1
- package/dist/react/media/privateClient.d.ts +0 -34
- package/dist/react/media/privateClient.d.ts.map +0 -1
- package/dist/react/media/privateClient.js +0 -36
- package/dist/react/media/privateClient.js.map +0 -1
- package/dist/react/media/usePrivateUnlock.d.ts +0 -25
- package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
- package/dist/react/media/usePrivateUnlock.js +0 -68
- package/dist/react/media/usePrivateUnlock.js.map +0 -1
- package/src/react/media/PrivateIndexView.tsx +0 -113
- package/src/react/media/PrivateModeBadge.tsx +0 -50
- package/src/react/media/privateClient.spec.ts +0 -113
- package/src/react/media/privateClient.ts +0 -82
- 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={() =>
|
|
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={
|
|
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={
|
|
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
|
+
});
|
package/src/react/index.ts
CHANGED
|
@@ -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
|
-
<
|
|
205
|
+
<SecretInput
|
|
205
206
|
id="ml-current"
|
|
206
|
-
|
|
207
|
+
label="current master password"
|
|
207
208
|
autoComplete="current-password"
|
|
209
|
+
showCopy={false}
|
|
208
210
|
value={current}
|
|
209
211
|
disabled={busy}
|
|
210
|
-
onChange={
|
|
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
|
-
<
|
|
219
|
+
<SecretInput
|
|
218
220
|
id="ml-next"
|
|
219
|
-
|
|
221
|
+
label="new master password"
|
|
220
222
|
autoComplete="new-password"
|
|
223
|
+
showCopy={false}
|
|
221
224
|
value={next}
|
|
222
225
|
disabled={busy}
|
|
223
|
-
onChange={
|
|
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
|
-
<
|
|
233
|
+
<SecretInput
|
|
231
234
|
id="ml-confirm"
|
|
232
|
-
|
|
235
|
+
label="repeated new master password"
|
|
233
236
|
autoComplete="new-password"
|
|
237
|
+
showCopy={false}
|
|
234
238
|
value={confirm}
|
|
235
239
|
disabled={busy}
|
|
236
|
-
onChange={
|
|
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
|
|
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
|
-
* ──
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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
|
-
|
|
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
|
|
16
|
-
//
|
|
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
|
|
29
|
-
//
|
|
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
|
-
/**
|
|
38
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
8
|
-
//
|
|
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
|
|
342
|
+
else {
|
|
343
|
+
hls.swapAudioCodec?.();
|
|
344
|
+
hls.recoverMediaError();
|
|
345
|
+
}
|
|
343
346
|
return;
|
|
344
347
|
}
|
|
345
348
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
package/src/react/media/index.ts
CHANGED
|
@@ -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.
|
package/src/react/sessionAuth.ts
CHANGED
|
@@ -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,
|
|
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
|
-
* (`
|
|
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 =
|