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
@@ -419,7 +419,6 @@ const resolveRelative = (fromFile: string, specifier: string): string | null =>
419
419
  const base = `/${stack.join('/')}`.replace(/\.js$/, '');
420
420
  if (SOURCE.test(base)) return base;
421
421
  for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) {
422
- // biome-ignore lint/nursery/noAwaitInLoop: synchronous existence check, no await here
423
422
  if (Bun.file(candidate).size > 0) return candidate;
424
423
  }
425
424
  return null;
@@ -442,7 +441,7 @@ export const buildGraph = async (): Promise<Map<string, string[]>> => {
442
441
  for (const match of text.matchAll(SPECIFIER)) {
443
442
  const target = resolveRelative(file, match[1] as string);
444
443
  // Outside `src/react` is outside every area — the utility scan never sees it.
445
- if (target && target.startsWith(`${REACT_DIR}/`) && !SPEC.test(target)) out.add(target);
444
+ if (target?.startsWith(`${REACT_DIR}/`) && !SPEC.test(target)) out.add(target);
446
445
  }
447
446
  graph.set(file, [...out]);
448
447
  }),
@@ -92,14 +92,17 @@ const NO_STATIC_OPTIONAL_PEERS = [
92
92
  },
93
93
  ] as const;
94
94
 
95
- const OPTIONAL_PEERS = new Set(
96
- Object.entries(
95
+ const OPTIONAL_PEERS = new Set([
96
+ ...Object.entries(
97
97
  (pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> })
98
98
  .peerDependenciesMeta ?? {},
99
99
  )
100
100
  .filter(([, meta]) => meta?.optional === true)
101
101
  .map(([name]) => name),
102
- );
102
+ // Optional in every sense but npm's (task 2087): declared in `unpeered`, not peered only
103
+ // because npm cannot resolve them. The barrel must not reach them statically either.
104
+ ...Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {}),
105
+ ]);
103
106
 
104
107
  /**
105
108
  * `import x from "pkg"` · `export … from "pkg"` · the side-effect-only `import "pkg";`.
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pkg from '../package.json';
5
+
6
+ /**
7
+ * `bundlerOnly.subpaths` in `package.json` is EXACTLY the set of export subpaths whose
8
+ * built graph imports a stylesheet — in both directions.
9
+ *
10
+ * Task 2087: every cursedbelt publish runs `tools/proveInstall.ts`, and Node refused 12 of its
11
+ * entries with `ERR_UNKNOWN_FILE_EXTENSION ".css"`. That is by design (a component that ships
12
+ * its own stylesheet needs a bundler), but a proof that is red on every publish trains people to
13
+ * ignore it. The fix is an excuse the package DECLARES rather than one the tool grants globally —
14
+ * and a declaration only stays true if something re-derives it. This does: every subpath is
15
+ * bundled with its packages external, and a subpath counts when its output has a CSS sibling (its
16
+ * own `.css` imports) or imports a package's `.css` (`@vidstack/…/theme.css`). A new CSS import
17
+ * on an undeclared subpath is red here, before it is red in a publish; a declared subpath that
18
+ * stopped importing CSS is red too, so the excuse cannot outlive its reason.
19
+ */
20
+
21
+ const REPO = fileURLToPath(new URL('..', import.meta.url));
22
+
23
+ type Reason = 'css' | 'directoryImport';
24
+ const declared = (reason: Reason): string[] =>
25
+ [...((pkg as { bundlerOnly?: Record<Reason, string[]> }).bundlerOnly?.[reason] ?? [])].sort();
26
+
27
+ /** A deep specifier into a package with NO `exports` map, naming a directory — Node ESM refuses it. */
28
+ const isDirectoryImport = (specifier: string): boolean => {
29
+ const parts = specifier.split('/');
30
+ const n = specifier.startsWith('@') ? 2 : 1;
31
+ if (parts.length <= n) return false;
32
+ const name = parts.slice(0, n).join('/');
33
+ const manifest = `${REPO}node_modules/${name}/package.json`;
34
+ if (!existsSync(manifest) || JSON.parse(readFileSync(manifest, 'utf8')).exports) return false;
35
+ const target = `${REPO}node_modules/${specifier}`;
36
+ return existsSync(target) && statSync(target).isDirectory();
37
+ };
38
+
39
+ const SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
40
+
41
+ async function measure(): Promise<Record<Reason, string[]>> {
42
+ const entries = Object.entries(pkg.exports as Record<string, unknown>).flatMap(([subpath, target]) => {
43
+ const source = (target as { source?: string }).source;
44
+ return source && /\.tsx?$/.test(source) ? [[subpath, source.replace(/^\.\//, '')] as const] : [];
45
+ });
46
+ const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-bundler-only-${process.pid}`;
47
+ try {
48
+ const result = await Bun.build({
49
+ entrypoints: entries.map(([, source]) => `${REPO}${source}`),
50
+ root: REPO,
51
+ target: 'bun',
52
+ packages: 'external',
53
+ outdir,
54
+ });
55
+ if (!result.success) throw new Error(`could not bundle — a check that cannot measure is not a pass:\n${result.logs.join('\n')}`);
56
+ const emitted = new Set(result.outputs.map((o) => o.path));
57
+ const out: Record<Reason, string[]> = { css: [], directoryImport: [] };
58
+ for (const [subpath, source] of entries) {
59
+ const js = `${outdir}/${source.replace(/\.tsx?$/, '.js')}`;
60
+ const code = readFileSync(js, 'utf8');
61
+ const specifiers = [...code.matchAll(SPECIFIER)].map((m) => (m[1] ?? m[2]) as string);
62
+ if (emitted.has(js.replace(/\.js$/, '.css')) || specifiers.some((s) => s.endsWith('.css'))) out.css.push(subpath);
63
+ if (specifiers.some((s) => !s.startsWith('.') && isDirectoryImport(s))) out.directoryImport.push(subpath);
64
+ }
65
+ out.css.sort();
66
+ out.directoryImport.sort();
67
+ return out;
68
+ } finally {
69
+ rmSync(outdir, { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ describe('bundlerOnly', () => {
74
+ it('declares exactly the subpaths that import a stylesheet, and exactly those that reach a directory import', async () => {
75
+ const measured = await measure();
76
+ // The measurement must have seen the cases it exists for, or it measured nothing.
77
+ expect(measured.css).toContain('./react/media');
78
+ expect(measured.directoryImport).toContain('./react/disk-usage');
79
+ for (const reason of ['css', 'directoryImport'] as const) {
80
+ expect({
81
+ reason,
82
+ undeclared: measured[reason].filter((s) => !declared(reason).includes(s)),
83
+ stale: declared(reason).filter((s) => !measured[reason].includes(s)),
84
+ }).toEqual({ reason, undeclared: [], stale: [] });
85
+ }
86
+ });
87
+
88
+ it('names only subpaths that exist', () => {
89
+ const exported = Object.keys(pkg.exports as Record<string, unknown>);
90
+ for (const reason of ['css', 'directoryImport'] as const) expect(declared(reason).filter((s) => !exported.includes(s))).toEqual([]);
91
+ });
92
+ });
93
+
94
+ describe('unpeered', () => {
95
+ it('is disjoint from peerDependencies — a package is peered or unpeered, never both', () => {
96
+ const unpeered = Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {});
97
+ expect(unpeered.length).toBeGreaterThan(0);
98
+ const peers = Object.keys((pkg as { peerDependencies?: Record<string, string> }).peerDependencies ?? {});
99
+ expect(unpeered.filter((name) => peers.includes(name))).toEqual([]);
100
+ });
101
+ });
@@ -20,6 +20,10 @@ const declared = new Set([
20
20
  pkg.name,
21
21
  ...Object.keys((pkg as { dependencies?: Record<string, string> }).dependencies ?? {}),
22
22
  ...Object.keys((pkg as { peerDependencies?: Record<string, string> }).peerDependencies ?? {}),
23
+ // `unpeered` — imported by a subpath, declared, and kept OUT of peerDependencies only
24
+ // because npm cannot resolve them beside this package's own peers (task 2087; the block says
25
+ // why for each).
26
+ ...Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {}),
23
27
  ]);
24
28
  const builtin = new Set(builtinModules);
25
29
 
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readFileSync, rmSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pkg from '../package.json';
5
+
6
+ /**
7
+ * 🔴 `docs/API-REFERENCE.md`'s "What you install" table is the ONE place an importing app learns
8
+ * that `cursedbelt/react/charts` needs `recharts` — and this spec is what keeps it true.
9
+ *
10
+ * Task 104 (2026-09-17): *"`cursedbelt/react/charts` hard-fails the build without `recharts`,
11
+ * and the error names a `__vite-optional-peer-dep` module rather than the package."* The fix is
12
+ * one install; the cost was entirely in not knowing. The table says what to install, but it was
13
+ * hand-written — and measured the day this spec arrived it had already missed a subpath:
14
+ * `./react/media-gallery` statically reaches `@glideapps/glide-data-grid`, `@vidstack/react` and
15
+ * `marked`, and no row said so. (`peers-by-subpath.json` died of exactly this — task 272.)
16
+ *
17
+ * So: every public `./react/*` subpath is bundled with its packages left external, the OPTIONAL
18
+ * peers each one imports STATICALLY are read out of the bundle, and each must be named in that
19
+ * subpath's row of the table. A static optional peer is the case that breaks a build; a lazy
20
+ * `await import()` one breaks only the feature that loads it, and is the documented shape to
21
+ * copy, so it is not required here (a row may still name it).
22
+ *
23
+ * A row names a peer when its second cell carries the name in backticks, or a `@scope/*` set
24
+ * that covers it (the `@codemirror/*` set).
25
+ */
26
+
27
+ const REPO = fileURLToPath(new URL('..', import.meta.url));
28
+ const DOC = `${REPO}docs/API-REFERENCE.md`;
29
+ const HEADING = '### What you install, and what you don\'t';
30
+
31
+ const OPTIONAL = new Set([
32
+ ...Object.entries(
33
+ (pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> }).peerDependenciesMeta ??
34
+ {},
35
+ )
36
+ .filter(([, meta]) => meta?.optional === true)
37
+ .map(([name]) => name),
38
+ // `unpeered` (task 2087): optional peers npm cannot resolve, so not in peerDependencies —
39
+ // an app still has to install them, so the table still has to name them.
40
+ ...Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {}),
41
+ ]);
42
+
43
+ /** Same matcher as `barrelsReachNoOptionalPeer.spec.ts` — anchored on `from`, never on a dynamic import. */
44
+ const STATIC_SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
45
+
46
+ const packageOf = (specifier: string): string => {
47
+ const parts = specifier.split('/');
48
+ return (specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]) ?? specifier;
49
+ };
50
+
51
+ /** `./react/charts` → the optional peers its bundle imports statically. */
52
+ async function staticOptionalPeersBySubpath(): Promise<Map<string, string[]>> {
53
+ const entries = Object.entries(pkg.exports as Record<string, unknown>).flatMap(([subpath, target]) => {
54
+ const source = (target as { source?: string }).source;
55
+ return subpath.startsWith('./react/') && source && /\.tsx?$/.test(source) ? [[subpath, source] as const] : [];
56
+ });
57
+ const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-optional-peers-${process.pid}`;
58
+ try {
59
+ const result = await Bun.build({
60
+ entrypoints: entries.map(([, source]) => `${REPO}${source.replace(/^\.\//, '')}`),
61
+ root: `${REPO}src`,
62
+ target: 'bun',
63
+ packages: 'external',
64
+ outdir,
65
+ });
66
+ if (!result.success) throw new Error(`could not bundle the subpaths — a check that cannot measure is not a pass:\n${result.logs.join('\n')}`);
67
+ const out = new Map<string, string[]>();
68
+ for (const [subpath, source] of entries) {
69
+ const emitted = `${outdir}/${source.replace(/^\.\/src\//, '').replace(/\.tsx?$/, '.js')}`;
70
+ const text = readFileSync(emitted, 'utf8');
71
+ const found = new Set<string>();
72
+ for (const m of text.matchAll(STATIC_SPECIFIER)) {
73
+ const specifier = m[1] ?? m[2];
74
+ if (specifier && !specifier.startsWith('.') && OPTIONAL.has(packageOf(specifier))) found.add(packageOf(specifier));
75
+ }
76
+ out.set(subpath, [...found].sort());
77
+ }
78
+ return out;
79
+ } finally {
80
+ rmSync(outdir, { recursive: true, force: true });
81
+ }
82
+ }
83
+
84
+ /** The table's rows: every `cursedbelt/react/<name>` its first cell names (`…/y` continues the last), → its second cell. */
85
+ export function tableRows(markdown: string): Map<string, string> {
86
+ const start = markdown.indexOf(HEADING);
87
+ if (start < 0) throw new Error(`${DOC} has no "${HEADING}" section — the table this spec keeps honest is gone`);
88
+ const rows = new Map<string, string>();
89
+ for (const line of markdown.slice(start).split('\n').slice(1)) {
90
+ if (line.startsWith('#')) break;
91
+ if (!line.startsWith('|')) continue;
92
+ const [first = '', second = ''] = line.split('|').slice(1, 3);
93
+ for (const m of first.matchAll(/`cursedbelt\/(react\/[\w/-]+)`/g)) rows.set(`./${m[1]}`, second);
94
+ for (const m of first.matchAll(/`…\/([\w-]+)`/g)) rows.set(`./react/${m[1]}`, second);
95
+ }
96
+ return rows;
97
+ }
98
+
99
+ /** Does this cell name `peer` — literally, or through a `@scope/*` set? */
100
+ export function names(cell: string, peer: string): boolean {
101
+ if (cell.includes(`\`${peer}\``)) return true;
102
+ const scope = peer.startsWith('@') ? peer.split('/')[0] : null;
103
+ return scope !== null && cell.includes(`\`${scope}/*\``);
104
+ }
105
+
106
+ describe("the docs' what-you-install table", () => {
107
+ it('🔴 fails on a subpath whose static optional peer no row names — the task-104 shape', () => {
108
+ const rows = tableRows(`${HEADING}\n| import this | also \`bun add\` this |\n| --- | --- |\n| \`cursedbelt/react/code-editor\` | the \`@codemirror/*\` set |\n`);
109
+ expect(names(rows.get('./react/code-editor') ?? '', '@codemirror/view')).toBe(true);
110
+ expect(rows.get('./react/charts')).toBeUndefined();
111
+ expect(names('`marked`', 'recharts')).toBe(false);
112
+ });
113
+
114
+ it('names every optional peer each ./react/* subpath imports statically', async () => {
115
+ const rows = tableRows(readFileSync(DOC, 'utf8'));
116
+ const bySubpath = await staticOptionalPeersBySubpath();
117
+ // The measurement must have seen the case this exists for, or it measured nothing.
118
+ expect(bySubpath.get('./react/charts')).toContain('recharts');
119
+ const missing: string[] = [];
120
+ for (const [subpath, peers] of bySubpath) {
121
+ for (const peer of peers) {
122
+ if (!names(rows.get(subpath) ?? '', peer)) missing.push(`cursedbelt/${subpath.slice(2)} → ${peer}`);
123
+ }
124
+ }
125
+ expect(missing, `add these to the table in ${DOC}`).toEqual([]);
126
+ });
127
+ });
@@ -6,6 +6,14 @@ export { MessageScroller, type MessageScrollerProps } from './MessageScroller.js
6
6
  export { MessageThread, type MessageThreadProps } from './MessageThread.js';
7
7
  export { PresenceRoster, type PresenceRosterProps } from './PresenceRoster.js';
8
8
  export { StatusMarker, type StatusMarkerProps } from './StatusMarker.js';
9
+ export {
10
+ PRESENCE_RETRY_MS,
11
+ type PresenceEntryLike,
12
+ type PresenceSubscription,
13
+ presenceUsers,
14
+ type UsePresenceOptions,
15
+ usePresence,
16
+ } from './usePresence.js';
9
17
  export type {
10
18
  ChatAttachment,
11
19
  ChatAuthor,
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { act, render } from '@testing-library/react';
3
+ import { PresenceRoster } from './PresenceRoster.js';
4
+ import { presenceUsers, usePresence } from './usePresence.js';
5
+
6
+ /** A socket the test drives by hand: `open()`, `snapshot(rows)`, `drop()`. */
7
+ class FakeSocket extends EventTarget {
8
+ static made: FakeSocket[] = [];
9
+ closed = false;
10
+ constructor(readonly url: string) {
11
+ super();
12
+ FakeSocket.made.push(this);
13
+ }
14
+ open() {
15
+ this.dispatchEvent(new Event('open'));
16
+ }
17
+ snapshot(rows: unknown, event = 'presence') {
18
+ const message = new MessageEvent('message', { data: JSON.stringify({ event, data: rows }) });
19
+ this.dispatchEvent(message);
20
+ }
21
+ drop() {
22
+ this.dispatchEvent(new Event('close'));
23
+ }
24
+ close() {
25
+ if (this.closed) return;
26
+ this.closed = true;
27
+ this.dispatchEvent(new Event('close'));
28
+ }
29
+ }
30
+
31
+ const NAMES: Record<string, string> = { u1: 'Ada', u2: 'Brian', me: 'Me' };
32
+
33
+ function Harness({ enabled = true }: { enabled?: boolean }) {
34
+ const { users, connected } = usePresence({
35
+ channel: 'rooms',
36
+ enabled,
37
+ selfId: 'me',
38
+ resolveUser: (id) => ({ name: NAMES[id] }),
39
+ retryMs: [5],
40
+ location: { protocol: 'https:', host: 'roms.example' },
41
+ createSocket: (url) => new FakeSocket(url) as unknown as WebSocket,
42
+ });
43
+ return (
44
+ <div>
45
+ <span data-testid='state'>{connected ? 'on' : 'off'}</span>
46
+ <span data-testid='names'>{users.map((u) => u.name).join(',')}</span>
47
+ <PresenceRoster users={users} />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const last = () => FakeSocket.made[FakeSocket.made.length - 1] as FakeSocket;
53
+
54
+ describe('presenceUsers', () => {
55
+ test('one row per user (the earliest tab), arrival order, without the viewer', () => {
56
+ const users = presenceUsers(
57
+ [
58
+ { userId: 'u2', connectedAt: 30 },
59
+ { userId: 'me', connectedAt: 1 },
60
+ { userId: 'u1', connectedAt: 20 },
61
+ { userId: 'u2', connectedAt: 10 },
62
+ ],
63
+ 'me',
64
+ (id) => ({ name: NAMES[id] }),
65
+ );
66
+ expect(users).toEqual([
67
+ { id: 'u2', isOnline: true, name: 'Brian' },
68
+ { id: 'u1', isOnline: true, name: 'Ada' },
69
+ ]);
70
+ });
71
+
72
+ test("a row's own name wins over the resolver, and a malformed row is skipped", () => {
73
+ const users = presenceUsers([{ userId: 'u1', name: 'Ada L.' }, { userId: '' } as never, null as never], null, () => ({ name: 'x' }));
74
+ expect(users).toEqual([{ id: 'u1', isOnline: true, name: 'Ada L.' }]);
75
+ });
76
+ });
77
+
78
+ describe('usePresence', () => {
79
+ test('connects to /ws/<channel> over wss, and a join and a leave redraw the roster', () => {
80
+ FakeSocket.made = [];
81
+ const { getByTestId } = render(<Harness />);
82
+ expect(last().url).toBe('wss://roms.example/ws/rooms');
83
+ expect(getByTestId('state').textContent).toBe('off');
84
+
85
+ act(() => last().open());
86
+ expect(getByTestId('state').textContent).toBe('on');
87
+
88
+ act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u1', connectedAt: 2 }]));
89
+ expect(getByTestId('names').textContent).toBe('Ada');
90
+
91
+ act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u1', connectedAt: 2 }, { userId: 'u2', connectedAt: 3 }]));
92
+ expect(getByTestId('names').textContent).toBe('Ada,Brian');
93
+
94
+ act(() => last().snapshot([{ userId: 'me', connectedAt: 1 }, { userId: 'u2', connectedAt: 3 }]));
95
+ expect(getByTestId('names').textContent).toBe('Brian');
96
+
97
+ // Another channel's frame is not a presence snapshot.
98
+ act(() => last().snapshot([{ userId: 'u9' }], 'chat:message'));
99
+ expect(getByTestId('names').textContent).toBe('Brian');
100
+ });
101
+
102
+ test('🔴 a dropped socket empties the roster, then reconnects and refills it', async () => {
103
+ FakeSocket.made = [];
104
+ const { getByTestId } = render(<Harness />);
105
+ act(() => last().open());
106
+ act(() => last().snapshot([{ userId: 'u1', connectedAt: 2 }]));
107
+ expect(getByTestId('names').textContent).toBe('Ada');
108
+
109
+ const first = last();
110
+ act(() => first.drop());
111
+ expect(getByTestId('state').textContent).toBe('off');
112
+ expect(getByTestId('names').textContent).toBe('');
113
+
114
+ await act(async () => {
115
+ await new Promise((r) => setTimeout(r, 30));
116
+ });
117
+ expect(FakeSocket.made.length).toBe(2);
118
+ expect(last()).not.toBe(first);
119
+ act(() => last().open());
120
+ act(() => last().snapshot([{ userId: 'u2', connectedAt: 5 }]));
121
+ expect(getByTestId('state').textContent).toBe('on');
122
+ expect(getByTestId('names').textContent).toBe('Brian');
123
+ });
124
+
125
+ test('disabled opens no socket; unmount closes it and does not reconnect', async () => {
126
+ FakeSocket.made = [];
127
+ const off = render(<Harness enabled={false} />);
128
+ expect(FakeSocket.made.length).toBe(0);
129
+ off.unmount();
130
+
131
+ const on = render(<Harness />);
132
+ act(() => last().open());
133
+ on.unmount();
134
+ expect(last().closed).toBe(true);
135
+ await new Promise((r) => setTimeout(r, 30));
136
+ expect(FakeSocket.made.length).toBe(1);
137
+ });
138
+ });
@@ -0,0 +1,185 @@
1
+ import { buildChannelSocketUrl, parseWsMessage, type WsLocation } from 'cursedbelt-core/ws-wire';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import type { PresenceUser } from './types.js';
4
+
5
+ /**
6
+ * Who else is here — the CLIENT half of presence, so `PresenceRoster` has something to render.
7
+ *
8
+ * Task 078 (owner, 2026-09-19: *"show which other users are on apps"*). The library shipped the
9
+ * view (`PresenceRoster`) and the server (`cursedbelt-server`'s `createWsHub` with
10
+ * `presence: true`, whose `getPresence(channel)` is one `{ userId, connectedAt }` row per live
11
+ * client) and nothing in between — so the roster sat exported with zero consumers, and the one
12
+ * app that shows presence (`apps/family`, `src/live.ts`) hand-rolled its own socket. This is that
13
+ * socket, generalised: open `/ws/<channel>`, take every `presence` snapshot the channel
14
+ * broadcasts, and reconnect with a capped backoff when the socket drops.
15
+ *
16
+ * The server side a channel needs is one line in its lifecycle hooks:
17
+ *
18
+ * onConnect: (_c, hub) => hub.broadcast('rooms', 'presence', hub.getPresence('rooms')),
19
+ * onDisconnect: (_c, hub) => hub.broadcast('rooms', 'presence', hub.getPresence('rooms')),
20
+ *
21
+ * ── What it does with a snapshot ─────────────────────────────────────────────────────────
22
+ * · ONE row per user, not per tab — the same person in three tabs is one avatar — keeping the
23
+ * earliest `connectedAt`, so the roster's order is "who arrived first" and stable.
24
+ * · `selfId` drops the viewer: the roster is who ELSE is here.
25
+ * · `resolveUser` turns an id into a name/avatar; the hub deliberately ships only the id (its
26
+ * `PresenceEntry` is the "business-safe view" of a client). A row may also carry `name` /
27
+ * `avatarUrl` itself, for a channel that broadcasts richer rows.
28
+ *
29
+ * ── A dropped socket is an EMPTY roster, not a stale one ─────────────────────────────────
30
+ * On close the users go to `[]` and `connected` to false until the next snapshot. Showing the
31
+ * last list while disconnected would tell the viewer someone is here who may have left an hour
32
+ * ago; an empty strip is merely less informative. Same choice as family's `useLiveTree`.
33
+ */
34
+
35
+ /** One presence row as a hub channel broadcasts it (`cursedbelt-server`'s `PresenceEntry`). */
36
+ export interface PresenceEntryLike {
37
+ userId: string;
38
+ connectedAt?: number;
39
+ name?: string;
40
+ avatarUrl?: string;
41
+ }
42
+
43
+ export interface UsePresenceOptions {
44
+ /** The hub channel (`/ws/<channel>`) whose snapshots to follow. */
45
+ channel: string;
46
+ /** Room within the channel (`?room=`), when the channel is roomed. */
47
+ roomId?: string;
48
+ /** Off ⇒ no socket at all (e.g. signed out). Default true. */
49
+ enabled?: boolean;
50
+ /** The viewer's own id — left out of the roster. */
51
+ selfId?: string | null;
52
+ /** Name and avatar for an id the snapshot does not carry them for. */
53
+ resolveUser?: (userId: string) => { name?: string; avatarUrl?: string } | undefined;
54
+ /** The frame event carrying the snapshot. Default `'presence'`. */
55
+ event?: string;
56
+ /** Reconnect backoff, capped at its last entry. */
57
+ retryMs?: readonly number[];
58
+ /** Test/SSR seams. Default `window.location` and the global `WebSocket`. */
59
+ location?: WsLocation;
60
+ createSocket?: (url: string) => WebSocket;
61
+ }
62
+
63
+ export interface PresenceSubscription {
64
+ /** Everybody else on the channel now — pass straight to `<PresenceRoster users={…}>`. */
65
+ users: PresenceUser[];
66
+ /** Is the socket open? A closed one also means `users` is `[]`. */
67
+ connected: boolean;
68
+ }
69
+
70
+ /** Backoff, capped. A tab left open overnight must not hammer the box. */
71
+ export const PRESENCE_RETRY_MS = [500, 1_000, 2_000, 5_000, 10_000, 30_000] as const;
72
+
73
+ /**
74
+ * Collapse a snapshot to one {@link PresenceUser} per user, earliest arrival first, without
75
+ * `selfId`. Pure — the hook's whole judgement, exported so it is tested without a socket.
76
+ */
77
+ export function presenceUsers(
78
+ rows: readonly PresenceEntryLike[],
79
+ selfId?: string | null,
80
+ resolveUser?: UsePresenceOptions['resolveUser'],
81
+ ): PresenceUser[] {
82
+ const byUser = new Map<string, PresenceEntryLike>();
83
+ for (const row of rows) {
84
+ if (!row || typeof row.userId !== 'string' || row.userId === '') continue;
85
+ if (selfId != null && row.userId === selfId) continue;
86
+ const seen = byUser.get(row.userId);
87
+ if (!seen || (row.connectedAt ?? Infinity) < (seen.connectedAt ?? Infinity)) byUser.set(row.userId, row);
88
+ }
89
+ return [...byUser.values()]
90
+ .sort((a, b) => (a.connectedAt ?? Infinity) - (b.connectedAt ?? Infinity))
91
+ .map((row) => {
92
+ const resolved = resolveUser?.(row.userId);
93
+ const name = row.name ?? resolved?.name;
94
+ const avatarUrl = row.avatarUrl ?? resolved?.avatarUrl;
95
+ return {
96
+ id: row.userId,
97
+ isOnline: true,
98
+ ...(name !== undefined ? { name } : {}),
99
+ ...(avatarUrl !== undefined ? { avatarUrl } : {}),
100
+ };
101
+ });
102
+ }
103
+
104
+ /** The rows in a snapshot frame: the hub's bare array, or `{ users }` / `{ peers }` / `{ presence }`. */
105
+ function rowsOf(data: unknown): PresenceEntryLike[] | null {
106
+ if (Array.isArray(data)) return data as PresenceEntryLike[];
107
+ if (data && typeof data === 'object') {
108
+ for (const key of ['users', 'peers', 'presence'] as const) {
109
+ const value = (data as Record<string, unknown>)[key];
110
+ if (Array.isArray(value)) return value as PresenceEntryLike[];
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ export function usePresence(options: UsePresenceOptions): PresenceSubscription {
117
+ const {
118
+ channel,
119
+ roomId,
120
+ enabled = true,
121
+ selfId = null,
122
+ resolveUser,
123
+ event = 'presence',
124
+ retryMs = PRESENCE_RETRY_MS,
125
+ location,
126
+ createSocket,
127
+ } = options;
128
+ const [rows, setRows] = useState<PresenceEntryLike[]>([]);
129
+ const [connected, setConnected] = useState(false);
130
+ // The seams and backoff are read through a ref so an inline lambda or array literal in the
131
+ // caller does not tear the socket down on every render.
132
+ const seams = useRef({ location, createSocket, retryMs });
133
+ seams.current = { location, createSocket, retryMs };
134
+
135
+ useEffect(() => {
136
+ const loc = seams.current.location ?? (typeof window !== 'undefined' ? window.location : undefined);
137
+ if (!enabled || !loc) return;
138
+ let closed = false;
139
+ let attempt = 0;
140
+ let retry: ReturnType<typeof setTimeout> | null = null;
141
+ let socket: WebSocket | null = null;
142
+
143
+ const open = () => {
144
+ if (closed) return;
145
+ const url = buildChannelSocketUrl(channel, roomId, loc);
146
+ const make = seams.current.createSocket ?? ((u: string) => new WebSocket(u));
147
+ const ws = make(url);
148
+ socket = ws;
149
+ ws.addEventListener('open', () => {
150
+ attempt = 0;
151
+ setConnected(true);
152
+ });
153
+ ws.addEventListener('message', (message) => {
154
+ const frame = parseWsMessage(String((message as MessageEvent).data));
155
+ if (!frame || frame.event !== event) return;
156
+ const next = rowsOf(frame.data);
157
+ if (next) setRows(next);
158
+ });
159
+ ws.addEventListener('close', () => {
160
+ setConnected(false);
161
+ setRows([]);
162
+ if (closed) return;
163
+ const steps = seams.current.retryMs;
164
+ const wait = steps[Math.min(attempt, steps.length - 1)] ?? 30_000;
165
+ attempt += 1;
166
+ retry = setTimeout(open, wait);
167
+ });
168
+ // An `error` is always followed by a `close`; reconnecting from both would schedule two.
169
+ ws.addEventListener('error', () => ws.close());
170
+ };
171
+
172
+ open();
173
+ return () => {
174
+ closed = true;
175
+ if (retry) clearTimeout(retry);
176
+ socket?.close();
177
+ socket = null;
178
+ setConnected(false);
179
+ setRows([]);
180
+ };
181
+ }, [enabled, channel, roomId, event]);
182
+
183
+ const users = useMemo(() => presenceUsers(rows, selfId, resolveUser), [rows, selfId, resolveUser]);
184
+ return { users, connected };
185
+ }
@@ -1,4 +1,4 @@
1
- import { type CSSProperties, type ReactNode, useState } from 'react';
1
+ import { type CSSProperties, type KeyboardEvent, type ReactNode, useState } from 'react';
2
2
  import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js';
3
3
  import { CONTROL_HEIGHT_CLASS, type ControlSize } from '../lib/field.js';
4
4
  import { resolveClass, resolveStyle, type StyleableProps } from '../styling.js';
@@ -118,6 +118,11 @@ export interface SecretInputProps extends StyleableProps<SecretInputSlot> {
118
118
  'aria-invalid'?: boolean | 'true' | 'false';
119
119
  'aria-describedby'?: string;
120
120
  autoFocus?: boolean;
121
+ /** Disable typing while a form submits (the eye stays usable — seeing what you typed is
122
+ * harmless while it is in flight). */
123
+ disabled?: boolean;
124
+ /** Fires on Enter in THIS field — the login/rotation forms submit from their last field. */
125
+ onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
121
126
  className?: string;
122
127
  style?: CSSProperties;
123
128
  /** Container height on the shared control-height scale (`--control-h-*`, task
@@ -149,6 +154,8 @@ export const SecretInput = ({
149
154
  'aria-invalid': ariaInvalid,
150
155
  'aria-describedby': ariaDescribedBy,
151
156
  autoFocus = false,
157
+ disabled,
158
+ onEnter,
152
159
  className,
153
160
  style,
154
161
  classNames,
@@ -199,6 +206,10 @@ export const SecretInput = ({
199
206
  value={value}
200
207
  readOnly={readOnly}
201
208
  onChange={onChange ? (e) => onChange(e.target.value) : undefined}
209
+ onKeyDown={onEnter ? (e) => {
210
+ if (e.key === 'Enter') onEnter(e);
211
+ } : undefined}
212
+ disabled={disabled}
202
213
  placeholder={placeholder}
203
214
  aria-label={label}
204
215
  aria-invalid={ariaInvalid}