cursedbelt 4.7.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 (137) 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/index.d.ts +1 -0
  18. package/dist/react/index.d.ts.map +1 -1
  19. package/dist/react/index.js +1 -0
  20. package/dist/react/index.js.map +1 -1
  21. package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
  22. package/dist/react/master-lock/MasterLockDialog.js +2 -1
  23. package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
  24. package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
  25. package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
  26. package/dist/react/master-lock/MasterLockProvider.js +14 -20
  27. package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
  28. package/dist/react/media/MediaUploadControl.d.ts +3 -7
  29. package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
  30. package/dist/react/media/MediaUploadControl.js +4 -13
  31. package/dist/react/media/MediaUploadControl.js.map +1 -1
  32. package/dist/react/media/fileUploadClient.d.ts +0 -8
  33. package/dist/react/media/fileUploadClient.d.ts.map +1 -1
  34. package/dist/react/media/fileUploadClient.js +0 -8
  35. package/dist/react/media/fileUploadClient.js.map +1 -1
  36. package/dist/react/media/hlsSource.d.ts.map +1 -1
  37. package/dist/react/media/hlsSource.js +4 -2
  38. package/dist/react/media/hlsSource.js.map +1 -1
  39. package/dist/react/media/index.d.ts +0 -4
  40. package/dist/react/media/index.d.ts.map +1 -1
  41. package/dist/react/media/index.js +0 -4
  42. package/dist/react/media/index.js.map +1 -1
  43. package/dist/react/sessionAuth.d.ts +1 -2
  44. package/dist/react/sessionAuth.d.ts.map +1 -1
  45. package/dist/react/sessionAuth.js +2 -3
  46. package/dist/react/sessionAuth.js.map +1 -1
  47. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  48. package/dist/styles-areas/activity-bar.css +1 -1
  49. package/dist/styles-areas/auth.css +1 -1
  50. package/dist/styles-areas/charts.css +1 -1
  51. package/dist/styles-areas/chat.css +1 -1
  52. package/dist/styles-areas/core.css +1 -1
  53. package/dist/styles-areas/data-table.css +1 -1
  54. package/dist/styles-areas/disk-usage.css +1 -1
  55. package/dist/styles-areas/fields.css +1 -1
  56. package/dist/styles-areas/master-detail.css +1 -1
  57. package/dist/styles-areas/master-lock.css +108 -2
  58. package/dist/styles-areas/media-gallery.css +1 -1
  59. package/dist/styles-areas/media.css +1 -17
  60. package/dist/styles-areas/nav.css +1 -1
  61. package/dist/styles-areas/notifications.css +1 -1
  62. package/dist/styles-areas/spreadsheet.css +1 -1
  63. package/dist/styles-areas/stats.css +1 -1
  64. package/dist/styles-areas/wizard.css +1 -1
  65. package/dist/styles-areas/workbook-viewer.css +1 -1
  66. package/dist/styles-utilities.css +0 -16
  67. package/dist/testing/_noBun.d.ts +2 -0
  68. package/dist/testing/_noBun.d.ts.map +1 -0
  69. package/dist/testing/_noBun.js +10 -0
  70. package/dist/testing/_noBun.js.map +1 -0
  71. package/package.json +42 -19
  72. package/scripts/checkAreaStyles.spec.ts +71 -0
  73. package/scripts/checkAreaStyles.ts +150 -0
  74. package/scripts/guardrailsEnforce.spec.ts +43 -0
  75. package/scripts/guardrailsEnforce.ts +46 -1
  76. package/scripts/styleAreas.ts +1 -2
  77. package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
  78. package/src/bundlerOnlyEntries.spec.ts +101 -0
  79. package/src/declaredImports.spec.ts +4 -0
  80. package/src/optionalPeersTable.spec.ts +127 -0
  81. package/src/react/chat/index.ts +8 -0
  82. package/src/react/chat/usePresence.spec.tsx +138 -0
  83. package/src/react/chat/usePresence.ts +185 -0
  84. package/src/react/components/SecretInput.tsx +12 -1
  85. package/src/react/components/TooltipImpl.tsx +54 -5
  86. package/src/react/index.ts +8 -0
  87. package/src/react/master-lock/MasterLockDialog.tsx +13 -9
  88. package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
  89. package/src/react/master-lock/MasterLockProvider.tsx +16 -19
  90. package/src/react/media/MediaUploadControl.tsx +8 -29
  91. package/src/react/media/fileUploadClient.ts +7 -17
  92. package/src/react/media/hlsSource.ts +4 -1
  93. package/src/react/media/index.ts +0 -16
  94. package/src/react/sessionAuth.ts +2 -3
  95. package/src/scriptsResolve.spec.ts +66 -0
  96. package/src/styles-areas/activity-bar.css +1 -1
  97. package/src/styles-areas/auth.css +1 -1
  98. package/src/styles-areas/charts.css +1 -1
  99. package/src/styles-areas/chat.css +1 -1
  100. package/src/styles-areas/core.css +1 -1
  101. package/src/styles-areas/data-table.css +1 -1
  102. package/src/styles-areas/disk-usage.css +1 -1
  103. package/src/styles-areas/fields.css +1 -1
  104. package/src/styles-areas/master-detail.css +1 -1
  105. package/src/styles-areas/master-lock.css +108 -2
  106. package/src/styles-areas/media-gallery.css +1 -1
  107. package/src/styles-areas/media.css +1 -17
  108. package/src/styles-areas/nav.css +1 -1
  109. package/src/styles-areas/notifications.css +1 -1
  110. package/src/styles-areas/spreadsheet.css +1 -1
  111. package/src/styles-areas/stats.css +1 -1
  112. package/src/styles-areas/wizard.css +1 -1
  113. package/src/styles-areas/workbook-viewer.css +1 -1
  114. package/src/styles-utilities.css +0 -16
  115. package/src/testing/_noBun.ts +12 -0
  116. package/src/verifyGraph.spec.ts +11 -0
  117. package/dist/react/media/PrivateIndexView.d.ts +0 -6
  118. package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
  119. package/dist/react/media/PrivateIndexView.js +0 -44
  120. package/dist/react/media/PrivateIndexView.js.map +0 -1
  121. package/dist/react/media/PrivateModeBadge.d.ts +0 -17
  122. package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
  123. package/dist/react/media/PrivateModeBadge.js +0 -24
  124. package/dist/react/media/PrivateModeBadge.js.map +0 -1
  125. package/dist/react/media/privateClient.d.ts +0 -34
  126. package/dist/react/media/privateClient.d.ts.map +0 -1
  127. package/dist/react/media/privateClient.js +0 -36
  128. package/dist/react/media/privateClient.js.map +0 -1
  129. package/dist/react/media/usePrivateUnlock.d.ts +0 -25
  130. package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
  131. package/dist/react/media/usePrivateUnlock.js +0 -68
  132. package/dist/react/media/usePrivateUnlock.js.map +0 -1
  133. package/src/react/media/PrivateIndexView.tsx +0 -113
  134. package/src/react/media/PrivateModeBadge.tsx +0 -50
  135. package/src/react/media/privateClient.spec.ts +0 -113
  136. package/src/react/media/privateClient.ts +0 -82
  137. package/src/react/media/usePrivateUnlock.ts +0 -132
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Does an app's BUILT CSS style every cursedbelt class its BUILT JS uses? — the completeness
4
+ * check the per-area stylesheets need before any app may adopt them (task 079).
5
+ *
6
+ * ── Why adopting the areas was blocked on this ──────────────────────────────────────────
7
+ * `cursedbelt/styles-areas/*.css` lets an app import `core` plus the areas it renders instead
8
+ * of the 135 KB union (`scripts/generateAreaStyles.ts` has the numbers). The catch is the one
9
+ * `apps/flix/scripts/check-belt-styles.ts` was built for: under-select ONE area and the app
10
+ * typechecks, tests and builds green while a component renders unstyled. A byte count cannot
11
+ * tell "the right ten areas" from "nine of them" — a 4 KB miss is invisible in it.
12
+ *
13
+ * ── How it decides, with no new source of truth ─────────────────────────────────────────
14
+ * 1. The UNION — every utility cursedbelt's components use — is this package's own
15
+ * `src/styles-utilities.css`. Its class selectors are the universe.
16
+ * 2. What the app's JS references: every whitespace/quote-delimited token in the app's built
17
+ * `*.js` that is ALSO a union class. (Tailwind's extractor works the same way; a class
18
+ * name survives minification because it is a string.)
19
+ * 3. What the app's CSS styles: every class selector in the app's built `*.css`.
20
+ * 4. (2) minus (3) is a class cursedbelt draws with and this app ships no rule for. Each one is
21
+ * reported with the AREA that carries it (`src/styles-areas/*.css`), which is the fix.
22
+ *
23
+ * A false positive is possible — an app string that happens to equal a utility name and is not
24
+ * used as a class — and it is the cheap direction: adding the area costs bytes, missing one
25
+ * costs a broken screen on a live hostname. A class that appears in NO area is excluded (the
26
+ * area split already proves core-plus-every-area equals the union, `src/stylesAreas.spec.ts`).
27
+ *
28
+ * Usage, from an app, after its build:
29
+ * bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist
30
+ * Exit 0 = complete, 1 = a class is unstyled (listed with its area), 2 = could not measure
31
+ * (no JS or no CSS under the directory — a check that measured nothing has not passed).
32
+ */
33
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
34
+ import { basename, dirname, join, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const HERE = dirname(fileURLToPath(import.meta.url));
38
+ /** This package's `src`, from `scripts/` (source) or `dist/scripts/` (compiled). */
39
+ const SRC = [resolve(HERE, '../src'), resolve(HERE, '../../src')].find((d) =>
40
+ existsSync(join(d, 'styles-utilities.css')),
41
+ );
42
+
43
+ /** Undo CSS identifier escaping: `\:` → `:`, `\32 xl` → `2xl`. */
44
+ function unescapeIdent(raw: string): string {
45
+ return raw.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_m, hex: string | undefined, ch: string | undefined) =>
46
+ hex ? String.fromCodePoint(Number.parseInt(hex, 16)) : (ch ?? ''),
47
+ );
48
+ }
49
+
50
+ /** Every class selector in a stylesheet, unescaped. Comments are dropped first. */
51
+ export function classesInCss(css: string): Set<string> {
52
+ const out = new Set<string>();
53
+ const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
54
+ // Only selector text — the part before each `{` — so a `.5rem` in a declaration never counts.
55
+ for (const selector of code.split('{').map((chunk) => chunk.slice(chunk.lastIndexOf('}') + 1).split(';').pop() ?? '')) {
56
+ for (const m of selector.matchAll(/\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[A-Za-z0-9_-])+)/g)) {
57
+ const name = unescapeIdent(m[1] as string);
58
+ if (/^[0-9]/.test(name) && !/[a-z]/i.test(name)) continue; // `.5` in a stray number
59
+ out.add(name);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /** Every whitespace/quote/backtick-delimited token in a JS bundle. */
66
+ export function tokensInJs(js: string): Set<string> {
67
+ return new Set(js.split(/[\s"'`]+/).filter((t) => t.length > 0 && t.length < 200));
68
+ }
69
+
70
+ function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
71
+ for (const entry of readdirSync(dir)) {
72
+ if (entry === 'node_modules') continue;
73
+ const full = join(dir, entry);
74
+ if (statSync(full).isDirectory()) filesUnder(full, ext, out);
75
+ else if (entry.endsWith(ext)) out.push(full);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ export interface AreaCheckResult {
81
+ /** Belt classes the JS uses and the CSS does not style → the area that carries each. */
82
+ missing: { className: string; area: string }[];
83
+ jsFiles: number;
84
+ cssFiles: number;
85
+ /** How many belt classes the JS references — a zero here means the check saw nothing. */
86
+ referenced: number;
87
+ }
88
+
89
+ /** The measurement, over explicit texts — what the spec drives. */
90
+ export function checkAreaStyles(
91
+ input: { js: string[]; css: string[] },
92
+ belt: { union: string; areas: Record<string, string> },
93
+ ): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles'> {
94
+ const union = classesInCss(belt.union);
95
+ const areaOf = new Map<string, string>();
96
+ for (const [area, css] of Object.entries(belt.areas)) {
97
+ for (const name of classesInCss(css)) if (!areaOf.has(name)) areaOf.set(name, area);
98
+ }
99
+ const emitted = new Set<string>();
100
+ for (const css of input.css) for (const name of classesInCss(css)) emitted.add(name);
101
+ const referenced = new Set<string>();
102
+ for (const js of input.js) for (const token of tokensInJs(js)) if (union.has(token) && areaOf.has(token)) referenced.add(token);
103
+ const missing = [...referenced]
104
+ .filter((name) => !emitted.has(name))
105
+ .sort()
106
+ .map((className) => ({ className, area: areaOf.get(className) as string }));
107
+ return { missing, referenced: referenced.size };
108
+ }
109
+
110
+ /** Read this package's union + area sheets. */
111
+ export function readBeltSheets(src: string | undefined = SRC): { union: string; areas: Record<string, string> } {
112
+ if (!src) throw new Error("cursedbelt's src/styles-utilities.css is not beside this script — the package is incomplete");
113
+ const areasDir = join(src, 'styles-areas');
114
+ const areas: Record<string, string> = {};
115
+ for (const file of readdirSync(areasDir)) if (file.endsWith('.css')) areas[basename(file, '.css')] = readFileSync(join(areasDir, file), 'utf8');
116
+ return { union: readFileSync(join(src, 'styles-utilities.css'), 'utf8'), areas };
117
+ }
118
+
119
+ export function checkDist(distDir: string): AreaCheckResult {
120
+ const jsFiles = filesUnder(distDir, '.js');
121
+ const cssFiles = filesUnder(distDir, '.css');
122
+ const result = checkAreaStyles(
123
+ { js: jsFiles.map((f) => readFileSync(f, 'utf8')), css: cssFiles.map((f) => readFileSync(f, 'utf8')) },
124
+ readBeltSheets(),
125
+ );
126
+ return { ...result, jsFiles: jsFiles.length, cssFiles: cssFiles.length };
127
+ }
128
+
129
+ if (import.meta.main) {
130
+ const dir = resolve(process.argv[2] ?? 'dist');
131
+ if (!existsSync(dir)) {
132
+ console.error(`✗ ${dir} does not exist — build the app first. A check that measured nothing has not passed.`);
133
+ process.exit(2);
134
+ }
135
+ const result = checkDist(dir);
136
+ if (result.jsFiles === 0 || result.cssFiles === 0) {
137
+ console.error(`✗ ${dir} holds ${result.jsFiles} .js and ${result.cssFiles} .css file(s) — nothing to compare.`);
138
+ process.exit(2);
139
+ }
140
+ if (result.missing.length === 0) {
141
+ console.log(`✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled (${result.jsFiles} js, ${result.cssFiles} css).`);
142
+ process.exit(0);
143
+ }
144
+ const byArea = new Map<string, string[]>();
145
+ for (const { className, area } of result.missing) byArea.set(area, [...(byArea.get(area) ?? []), className]);
146
+ console.error(`✗ ${result.missing.length} cursedbelt class(es) the built JS uses have NO rule in the built CSS.`);
147
+ console.error(' Import the area that carries them (after core) — `@import "cursedbelt/styles-areas/<area>.css";`:');
148
+ for (const [area, names] of [...byArea].sort()) console.error(` ${area}.css ← ${names.slice(0, 8).join(' ')}${names.length > 8 ? ` … +${names.length - 8}` : ''}`);
149
+ process.exit(1);
150
+ }
@@ -791,6 +791,14 @@ describe('no-native-dialog', () => {
791
791
  expect(check('no-native-dialog', prompted)).toHaveLength(0);
792
792
  });
793
793
 
794
+ test("🔴 a sibling import with the Node `.js` extension is the belt one too (task 269's spelling)", () => {
795
+ // DeleteButton's own `import { confirm } from '../confirm.js'` was reported as a native
796
+ // dialog from the day every relative specifier gained its `.js` until 2026-09-23.
797
+ const source = `import { confirm, type ConfirmOptions } from '../confirm.js';
798
+ const ok = await confirm({ title: 'Delete?' });`;
799
+ expect(check('no-native-dialog', source)).toHaveLength(0);
800
+ });
801
+
794
802
  test('🔴 a belt import does NOT excuse an explicit window.confirm in the same file', () => {
795
803
  // The migration's half-done state: one call site converted, one left behind.
796
804
  // Exempting the whole FILE on the strength of its import list is how that
@@ -1176,3 +1184,38 @@ describe('--exclude', () => {
1176
1184
  });
1177
1185
  });
1178
1186
  });
1187
+
1188
+ describe('no-raw-password', () => {
1189
+ test('🔴 flags the COMPONENT form no-raw-inputs is blind to', () => {
1190
+ expect(check('no-raw-password', `<Input id="ml-current" type="password" />`)).toHaveLength(1);
1191
+ expect(check('no-raw-inputs', `<Input id="ml-current" type="password" />`)).toHaveLength(0);
1192
+ });
1193
+
1194
+ test('flags a raw input, a JSX expression, a props object and template-literal HTML', () => {
1195
+ expect(check('no-raw-password', `<input type='password' />`)).toHaveLength(1);
1196
+ expect(check('no-raw-password', `<TextField type={"password"} />`)).toHaveLength(1);
1197
+ expect(check('no-raw-password', `const field = { name: 'pw', type: 'password' };`)).toHaveLength(1);
1198
+ expect(check('no-raw-password', 'const html = `<input name="pw" type="password">`;')).toHaveLength(1);
1199
+ });
1200
+
1201
+ test("passes SecretInput's own toggle — a ternary IS the eye", () => {
1202
+ expect(check('no-raw-password', ` type={shown ? 'text' : 'password'}`)).toHaveLength(0);
1203
+ });
1204
+
1205
+ test('passes an ignore-commented line, same line or the block above', () => {
1206
+ expect(
1207
+ check('no-raw-password', `<input type="password" /> {/* guardrails-ignore no-raw-password: server page, own eye */}`),
1208
+ ).toHaveLength(0);
1209
+ expect(
1210
+ check(
1211
+ 'no-raw-password',
1212
+ `// guardrails-ignore no-raw-password: server-rendered, carries its own reveal button
1213
+ const html = \`<input type="password">\`;`,
1214
+ ),
1215
+ ).toHaveLength(0);
1216
+ });
1217
+
1218
+ test('prose in a comment is not a field', () => {
1219
+ expect(check('no-raw-password', `// never write type="password" by hand`)).toHaveLength(0);
1220
+ });
1221
+ });
@@ -122,6 +122,10 @@
122
122
  // `--reimplements <Name,…>` from `fleet/standards/CURSEDBELT_INVENTORY.md`; with no
123
123
  // list the rule is inert rather than guessing.
124
124
  //
125
+ // no-raw-password — owner, 2026-09-18: every password field has an eye. Flags
126
+ // `type="password"` on ANY tag or props object (the `<Input type="password">` form that
127
+ // `no-raw-inputs` cannot see) and in template-literal HTML. Use `SecretInput`.
128
+ //
125
129
  // Opt-out (mirrors this repo's existing `biome-ignore lint/<rule>: <reason>` convention —
126
130
  // see e.g. packages/cursedbelt/src/react/components/ButtonGroup.tsx): put a same-shaped
127
131
  // line comment as the FIRST thing inside the tag's attribute list (for `no-raw-action-button`
@@ -1487,7 +1491,9 @@ function importsBeltDialog(source: string, fn: string): boolean {
1487
1491
  // static, from the package
1488
1492
  new RegExp(`import\\s*${named}\\s*from\\s*${spec}`, "s").test(source) ||
1489
1493
  // static, from a sibling inside cursedbelt itself
1490
- new RegExp(`import\\s*${named}\\s*from\\s*['"][./]+(?:react/)?${fn}['"]`, "s").test(source) ||
1494
+ // (with or without the `.js` Node ESM needs — task 269 added it to every relative
1495
+ // specifier, and this pattern then reported `DeleteButton`'s own belt `confirm`)
1496
+ new RegExp(`import\\s*${named}\\s*from\\s*['"][./]+(?:react/)?${fn}(?:\\.[jt]sx?)?['"]`, "s").test(source) ||
1491
1497
  // dynamic — `const { prompt } = await import('cursedbelt/react/prompt')`
1492
1498
  new RegExp(`${named}\\s*=\\s*(?:await\\s+)?import\\(\\s*${spec}`, "s").test(source) ||
1493
1499
  // and its type-only companion, `typeof import('…').prompt`
@@ -1626,6 +1632,44 @@ const noFastTooltip: Rule = {
1626
1632
  },
1627
1633
  };
1628
1634
 
1635
+ // ── no-raw-password (owner, 2026-09-18: "all passwords need eye icon") ─────────
1636
+ // A password field the person typing into cannot reveal. `SecretInput` has carried the eye
1637
+ // (and an opt-out copy button, `showCopy={false}` for a login form) the whole time, and on
1638
+ // 2026-09-19 twenty-eight fields across the fleet still typed into the dark — three of them in
1639
+ // cursedbelt's OWN master-lock dialog. They accumulated under an enforced guardrails suite
1640
+ // because there was no rule about masked fields at all: `no-raw-inputs` is about bare TAGS, and
1641
+ // every one of the three belt fields was `<Input type="password">`, a component it never reads.
1642
+ //
1643
+ // So this rule is on the ATTRIBUTE, on any tag: a raw `<input>`, a component (`<Input`,
1644
+ // `<TextField`), a props object (`{ type: 'password' }`), and server-rendered HTML in a
1645
+ // template literal (`<input type="password">`), which is the one place a React primitive
1646
+ // cannot reach — that markup takes an eye of its own. `SecretInput`'s own
1647
+ // `type={shown ? 'text' : 'password'}` does not match: a ternary is a toggle, which is the
1648
+ // point. Comments are blanked first; specs are fixtures and are skipped.
1649
+ const PASSWORD_TYPE_RE =
1650
+ /\btype\s*=\s*(?:\{\s*)?["'`]password["'`]|\btype\s*:\s*["'`]password["'`]/;
1651
+ const PASSWORD_IGNORE_RE = /guardrails-ignore\s+no-raw-password\s*:/;
1652
+
1653
+ const noRawPassword: Rule = {
1654
+ name: "no-raw-password",
1655
+ description:
1656
+ "Owner, 2026-09-18: every password field has an eye. A `type=\"password\"` field the person cannot reveal — on any tag, component or props object — must be cursedbelt's `SecretInput` (`showCopy={false}` for a login form, `autoComplete` passed through). Server-rendered HTML that cannot import it carries its own reveal toggle and takes the per-line opt-out naming it.",
1657
+ check(filePath, source) {
1658
+ if (isTestSource(filePath.replace(/\\/g, "/"))) return [];
1659
+ const raws = source.split("\n");
1660
+ const scan = blankComments(source).split("\n");
1661
+ const out: Violation[] = [];
1662
+ for (const [i, line] of scan.entries()) {
1663
+ if (!PASSWORD_TYPE_RE.test(line)) continue;
1664
+ const raw = raws[i] ?? "";
1665
+ if (PASSWORD_IGNORE_RE.test(raw)) continue;
1666
+ if (PASSWORD_IGNORE_RE.test(raws.slice(Math.max(0, i - 3), i).join("\n"))) continue;
1667
+ out.push({ file: filePath, line: i + 1, snippet: raw.trim() });
1668
+ }
1669
+ return out;
1670
+ },
1671
+ };
1672
+
1629
1673
  export const RULES: Record<string, Rule> = {
1630
1674
  [noRawActionButton.name]: noRawActionButton,
1631
1675
  [noRawOverflow.name]: noRawOverflow,
@@ -1644,6 +1688,7 @@ export const RULES: Record<string, Rule> = {
1644
1688
  [noSelectAsActionMenu.name]: noSelectAsActionMenu,
1645
1689
  [noNativeDialog.name]: noNativeDialog,
1646
1690
  [noFastTooltip.name]: noFastTooltip,
1691
+ [noRawPassword.name]: noRawPassword,
1647
1692
  };
1648
1693
 
1649
1694
  // ── File-scoped allowances ──────────────────────────────────────────────────────
@@ -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,