cursedbelt 3.0.2 β†’ 4.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 (250) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +111 -31
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/styleAreas.ts +585 -0
  151. package/scripts/verify.ts +5 -0
  152. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  153. package/src/docsMatchTheSplit.spec.ts +98 -0
  154. package/src/fixtureAppCss.spec.ts +177 -0
  155. package/src/namedSubpathsResolve.spec.ts +35 -0
  156. package/src/publishShape.spec.ts +67 -0
  157. package/src/react/components/ListToolbar.spec.tsx +172 -0
  158. package/src/react/components/ListToolbar.tsx +151 -0
  159. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  160. package/src/react/file-tree/FileTree.tsx +1459 -309
  161. package/src/react/file-tree/fileTree.css +151 -0
  162. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  163. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  164. package/src/react/file-tree/index.ts +21 -0
  165. package/src/react/index.ts +1 -0
  166. package/src/react/lib/form.ts +51 -8
  167. package/src/react/media/DenseGalleryShell.tsx +108 -36
  168. package/src/react/media/FocusViewer.tsx +15 -8
  169. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  170. package/src/react/media/FrameGrabber.tsx +67 -0
  171. package/src/react/media/MediaResize.spec.tsx +84 -14
  172. package/src/react/media/MediaResize.tsx +51 -19
  173. package/src/react/media/VideoChapterEditor.tsx +428 -0
  174. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  175. package/src/react/media/VideoPlayer.tsx +158 -11
  176. package/src/react/media/VideoTurner.spec.tsx +67 -0
  177. package/src/react/media/VideoTurner.tsx +70 -15
  178. package/src/react/media/denseGallery.css +104 -46
  179. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  180. package/src/react/media/hlsSource.spec.ts +8 -0
  181. package/src/react/media/hlsSource.ts +189 -1
  182. package/src/react/media/index.ts +14 -0
  183. package/src/react/media/mediaResize.css +127 -54
  184. package/src/react/media/pictureEditor.css +52 -6
  185. package/src/react/media/videoChapterEditor.css +203 -0
  186. package/src/react/media/videoChapters.spec.ts +176 -0
  187. package/src/react/media/videoChapters.ts +181 -0
  188. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  189. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  190. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  191. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  192. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  193. package/src/react/media-gallery/index.ts +7 -0
  194. package/src/react/media-gallery/mediaGallery.css +180 -4
  195. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  196. package/src/react/media-gallery/mediaMeta.ts +101 -0
  197. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  198. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  199. package/src/react/media-gallery/types.ts +34 -0
  200. package/src/shippedFilesAreTracked.spec.ts +69 -0
  201. package/src/styles-areas/activity-bar.css +30 -0
  202. package/src/styles-areas/analytics.css +672 -0
  203. package/src/styles-areas/auth.css +154 -0
  204. package/src/styles-areas/calendar.css +416 -0
  205. package/src/styles-areas/chart-table.css +41 -0
  206. package/src/styles-areas/charts.css +89 -0
  207. package/src/styles-areas/chat.css +364 -0
  208. package/src/styles-areas/clipboard.css +26 -0
  209. package/src/styles-areas/code-editor.css +130 -0
  210. package/src/styles-areas/companion-link.css +70 -0
  211. package/src/styles-areas/core.css +2503 -0
  212. package/src/styles-areas/dashboard-grid.css +84 -0
  213. package/src/styles-areas/data-table.css +657 -0
  214. package/src/styles-areas/deep-link.css +26 -0
  215. package/src/styles-areas/diff-viewer.css +30 -0
  216. package/src/styles-areas/disk-usage.css +262 -0
  217. package/src/styles-areas/emoji.css +154 -0
  218. package/src/styles-areas/fields.css +582 -0
  219. package/src/styles-areas/file-tree.css +33 -0
  220. package/src/styles-areas/filter-rail.css +235 -0
  221. package/src/styles-areas/folder-tree.css +219 -0
  222. package/src/styles-areas/keep-awake.css +26 -0
  223. package/src/styles-areas/layout-engine.css +239 -0
  224. package/src/styles-areas/markdown.css +87 -0
  225. package/src/styles-areas/master-detail.css +239 -0
  226. package/src/styles-areas/master-lock.css +26 -0
  227. package/src/styles-areas/media-gallery.css +351 -0
  228. package/src/styles-areas/media.css +658 -0
  229. package/src/styles-areas/nav.css +488 -0
  230. package/src/styles-areas/notifications.css +245 -0
  231. package/src/styles-areas/overlays.css +133 -0
  232. package/src/styles-areas/palette.css +140 -0
  233. package/src/styles-areas/pdf-viewer.css +47 -0
  234. package/src/styles-areas/problem-detail.css +97 -0
  235. package/src/styles-areas/rich-text.css +167 -0
  236. package/src/styles-areas/sharing.css +153 -0
  237. package/src/styles-areas/spreadsheet.css +37 -0
  238. package/src/styles-areas/stats.css +281 -0
  239. package/src/styles-areas/test-report.css +133 -0
  240. package/src/styles-areas/virtual.css +30 -0
  241. package/src/styles-areas/wizard.css +381 -0
  242. package/src/styles-areas/workbench.css +543 -0
  243. package/src/styles-areas/workbook-viewer.css +44 -0
  244. package/src/styles-static.css +34 -23
  245. package/src/styles.css +34 -23
  246. package/src/stylesAreas.spec.ts +247 -0
  247. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  248. package/src/testFilesRunInParallel.spec.ts +79 -0
  249. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  250. package/src/verifyGraph.spec.ts +6 -0
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `bun scripts/fixtureAppCss.ts [--prune]` β€” measure what a consuming app emits,
4
+ * and refuse to let it grow.
5
+ *
6
+ * ## What this exists for
7
+ *
8
+ * Three apps shipped the union of this design system before anyone counted it,
9
+ * then four, then five; the fifth arrived with a 173-line `@source` list that
10
+ * could not have worked and a 494 kB entry chunk. Every one of those numbers was
11
+ * measured from the CONSUMER side, in a repo that cannot change the thing causing
12
+ * it, and none of them was measured twice β€” so nothing here could tell whether the
13
+ * cost was going up or down.
14
+ *
15
+ * πŸ”΄ The check has to live in the package, and it may not reach into `apps/*`:
16
+ * this generation's third rule is that a repo's gate proves that repo. So the
17
+ * package keeps its own consumer β€” `demo/fixtures/lean-app`, a small releases
18
+ * console built out of cursedbelt's public subpaths β€” compiles its stylesheet the
19
+ * way an app's bundler will, and records the byte count here.
20
+ *
21
+ * ## The two routes, and why both are compiled every run
22
+ *
23
+ * UNION β€” `styles-static.css` + `styles-utilities.css`, which is what every app
24
+ * ships today and what `@import "cursedbelt/styles.css"` is equivalent
25
+ * to (measured at 54 bytes' difference; see generateUtilityStyles.ts).
26
+ * AREAS β€” `styles-static.css` + the areas `demo/fixtures/lean-app/app.css`
27
+ * declares, and no `@source` into this package at all.
28
+ *
29
+ * Both, not just the second, because a ratchet on one number cannot tell a real
30
+ * saving from a design system that shrank. The RATIO is the claim; the absolute
31
+ * area number is the ratchet.
32
+ *
33
+ * ## The ratchet
34
+ *
35
+ * `fixtureAppCss.baseline` records both counts. `--prune` lowers them and REFUSES
36
+ * to raise, the same bargain `publicSurface.baseline` makes: a real increase β€” a
37
+ * new primitive in `core`, a component the fixture started rendering β€” is a hand
38
+ * edit in a commit that says why, which is a reviewable moment rather than a
39
+ * number that drifts. `src/fixtureAppCss.spec.ts` is what runs it in the gate, and
40
+ * it asserts the "not unstyled" half too: a stylesheet can always be made smaller
41
+ * by emitting less than the app needs.
42
+ */
43
+ import { Scanner } from '@tailwindcss/oxide';
44
+ import { REPO_ROOT } from './generateStaticStyles';
45
+ import { compileStylesheet } from './generateUtilityStyles';
46
+ import { AREAS, CORE, REACT_DIR, areaClosures, buildGraph, transitiveNeeds } from './styleAreas';
47
+
48
+ /** The named fixture app. One, deliberately: a second one is a second thing to keep honest. */
49
+ export const FIXTURE = 'demo/fixtures/lean-app';
50
+ export const FIXTURE_DIR = `${REPO_ROOT}/${FIXTURE}`;
51
+ export const FIXTURE_CSS = `${FIXTURE_DIR}/app.css`;
52
+ export const BASELINE = `${REPO_ROOT}/fixtureAppCss.baseline`;
53
+
54
+ /** `@import "cursedbelt/styles-areas/<area>.css";` β€” the fixture's one knob. */
55
+ const AREA_IMPORT = /@import\s+"cursedbelt\/styles-areas\/([a-z0-9-]+)\.css"/g;
56
+
57
+ /** Areas the fixture's stylesheet declares, in the order it imports them. */
58
+ export const declaredAreas = async (): Promise<string[]> => {
59
+ const css = await Bun.file(FIXTURE_CSS).text();
60
+ return [...css.matchAll(AREA_IMPORT)].map((m) => m[1] as string);
61
+ };
62
+
63
+ /** `from 'cursedbelt/<subpath>'` in the fixture's TSX, with the names it took. */
64
+ export interface FixtureImport {
65
+ /** The export subpath, `.`-prefixed as `package.json` spells it (`./react/charts`). */
66
+ readonly subpath: string;
67
+ /** Named bindings, for a barrel import. Empty for a deep subpath. */
68
+ readonly names: string[];
69
+ readonly file: string;
70
+ }
71
+
72
+ const IMPORT_BLOCK = /import\s+(?:type\s+)?(?:\{([^}]*)\}|[\w*\s,]+)\s*from\s*'(cursedbelt(?:\/[^']*)?)'/g;
73
+
74
+ export const fixtureImports = async (): Promise<FixtureImport[]> => {
75
+ const out: FixtureImport[] = [];
76
+ for (const rel of new Bun.Glob('**/*.tsx').scanSync({ cwd: FIXTURE_DIR, onlyFiles: true })) {
77
+ const text = await Bun.file(`${FIXTURE_DIR}/${rel}`).text();
78
+ for (const match of text.matchAll(IMPORT_BLOCK)) {
79
+ const names = (match[1] ?? '')
80
+ .split(',')
81
+ .map((n) => n.trim().replace(/^type\s+/, ''))
82
+ // `A as B` exports A; the local alias is the consumer's business.
83
+ .map((n) => (n.includes(' as ') ? (n.split(/\s+as\s+/)[0] as string) : n))
84
+ .filter(Boolean);
85
+ out.push({ subpath: (match[2] as string).replace(/^cursedbelt/, '.'), names, file: `${FIXTURE}/${rel}` });
86
+ }
87
+ }
88
+ return out;
89
+ };
90
+
91
+ /**
92
+ * `export { A, type B } from './x'` in the `react` barrel β†’ exported name β†’ module.
93
+ *
94
+ * Only the named form, on purpose. A fixture import the barrel re-exports with
95
+ * `export * from` cannot be attributed to one module by a reader either, and
96
+ * `resolveFixtureModules` fails naming it rather than guessing β€” an unattributed
97
+ * import is exactly the case where an app would silently under-declare its areas.
98
+ */
99
+ const NAMED_REEXPORT = /export\s+(?:type\s+)?\{([^}]*)\}\s*from\s*'(\.[^']+)'/g;
100
+
101
+ export const barrelMap = async (): Promise<Map<string, string>> => {
102
+ const text = await Bun.file(`${REACT_DIR}/index.ts`).text();
103
+ const out = new Map<string, string>();
104
+ for (const match of text.matchAll(NAMED_REEXPORT)) {
105
+ const target = match[2] as string;
106
+ for (const raw of (match[1] as string).split(',')) {
107
+ const name = raw.trim().replace(/^type\s+/, '');
108
+ const exported = name.includes(' as ') ? (name.split(/\s+as\s+/)[1] as string) : name;
109
+ if (exported) out.set(exported.trim(), target);
110
+ }
111
+ }
112
+ return out;
113
+ };
114
+
115
+ /** `./react/charts` β†’ `<repo>/src/react/charts/index.ts`, through the export map's `source` condition. */
116
+ const subpathSource = (subpath: string, exportsMap: Record<string, unknown>): string | null => {
117
+ const entry = exportsMap[subpath];
118
+ const target = typeof entry === 'string' ? entry : (entry as { source?: string } | undefined)?.source;
119
+ return typeof target === 'string' ? `${REPO_ROOT}/${target.replace(/^\.\//, '')}` : null;
120
+ };
121
+
122
+ export interface FixtureResolution {
123
+ /** Entry modules under `src/react` the fixture's imports land on. */
124
+ readonly entries: Map<string, FixtureImport>;
125
+ /** Imports nothing could attribute to a module β€” always a failure, never a skip. */
126
+ readonly unresolved: string[];
127
+ }
128
+
129
+ export const resolveFixtureModules = async (exportsMap: Record<string, unknown>): Promise<FixtureResolution> => {
130
+ const barrel = await barrelMap();
131
+ const entries = new Map<string, FixtureImport>();
132
+ const unresolved: string[] = [];
133
+ for (const imp of await fixtureImports()) {
134
+ const source = subpathSource(imp.subpath, exportsMap);
135
+ if (!source) {
136
+ unresolved.push(`${imp.file}: cursedbelt${imp.subpath.slice(1)} is not an export subpath`);
137
+ continue;
138
+ }
139
+ // The two barrels re-export the package; attribute each NAME instead, or the
140
+ // fixture would appear to use every module there is.
141
+ if (source === `${REACT_DIR}/index.ts`) {
142
+ for (const name of imp.names) {
143
+ const target = barrel.get(name);
144
+ if (!target) {
145
+ unresolved.push(
146
+ `${imp.file}: \`${name}\` is not re-exported by name from src/react/index.ts, so no area can be ` +
147
+ 'attributed to it β€” import it from its own subpath instead',
148
+ );
149
+ continue;
150
+ }
151
+ const resolved = `${REACT_DIR}/${target.replace(/^\.\//, '')}`;
152
+ for (const ext of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
153
+ if (Bun.file(`${resolved}${ext}`).size > 0) {
154
+ entries.set(`${resolved}${ext}`, imp);
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ continue;
160
+ }
161
+ entries.set(source, imp);
162
+ }
163
+ return { entries, unresolved };
164
+ };
165
+
166
+ /** Every module under `src/react` the fixture's imports actually reach. */
167
+ export const fixtureClosure = async (exportsMap: Record<string, unknown>): Promise<{
168
+ modules: Set<string>;
169
+ resolution: FixtureResolution;
170
+ }> => {
171
+ const graph = await buildGraph();
172
+ const resolution = await resolveFixtureModules(exportsMap);
173
+ const seen = new Set<string>();
174
+ const queue = [...resolution.entries.keys()];
175
+ while (queue.length > 0) {
176
+ const next = queue.pop() as string;
177
+ if (seen.has(next)) continue;
178
+ seen.add(next);
179
+ for (const dep of graph.get(next) ?? []) queue.push(dep);
180
+ }
181
+ return { modules: seen, resolution };
182
+ };
183
+
184
+ /**
185
+ * Modules the fixture reaches that no declared area covers.
186
+ *
187
+ * πŸ”΄ This, not the byte count, is the assertion that stops the saving being fake.
188
+ * An app under-declares its areas by adding a component and forgetting the
189
+ * `@import` β€” and the symptom is a component with no classes, three repos away,
190
+ * on a screen nobody opened during review.
191
+ */
192
+ export const uncoveredModules = async (
193
+ declared: readonly string[],
194
+ exportsMap: Record<string, unknown>,
195
+ ): Promise<string[]> => {
196
+ const { closures } = await areaClosures();
197
+ const covered = new Set<string>();
198
+ for (const name of [CORE, ...declared]) for (const file of closures.get(name) ?? []) covered.add(file);
199
+ const { modules } = await fixtureClosure(exportsMap);
200
+ const owners = (file: string): string[] =>
201
+ AREAS.filter((a) => closures.get(a.name)?.has(file)).map((a) => a.name);
202
+ return [...modules]
203
+ .filter((file) => !covered.has(file))
204
+ .sort()
205
+ .map((file) => `${file.replace(`${REPO_ROOT}/`, '')} β€” covered by: ${owners(file).join(', ') || 'NO AREA'}`);
206
+ };
207
+
208
+ /** Declared areas whose `needs` the fixture's stylesheet does not also import. */
209
+ export const missingNeeds = (declared: readonly string[]): string[] => {
210
+ const have = new Set(declared);
211
+ return declared
212
+ .flatMap((name) => transitiveNeeds(name).filter((need) => !have.has(need)).map((need) => `${name} needs ${need}`))
213
+ .sort();
214
+ };
215
+
216
+ // ── the two compiles ──────────────────────────────────────────────────────────
217
+
218
+ /** Everything is resolved against `src/`, the way `styles.css`'s own `@import`s are. */
219
+ const STYLES_BASE = `${REPO_ROOT}/src`;
220
+
221
+ const buildCss = async (input: string): Promise<string> => {
222
+ const compiled = await compileStylesheet(input);
223
+ return compiled.build(new Scanner({ sources: compiled.sources }).scan());
224
+ };
225
+
226
+ /** The fixture's own classes, whichever route it takes. */
227
+ const HEAD = `@import "tailwindcss" source(none);\n@source "${FIXTURE_DIR}";\n@import "./styles-static.css";\n`;
228
+ const TAIL = '@import "./theme.css";\n';
229
+
230
+ export const unionRouteCss = (): Promise<string> => buildCss(`${HEAD}@import "./styles-utilities.css";\n${TAIL}`);
231
+
232
+ export const areaRouteCss = (declared: readonly string[]): Promise<string> =>
233
+ buildCss(`${HEAD}${declared.map((a) => `@import "./styles-areas/${a}.css";`).join('\n')}\n${TAIL}`);
234
+
235
+ /**
236
+ * What the fixture NEEDS: its own classes plus the classes of exactly the
237
+ * cursedbelt modules it reaches, and nothing else. The area route must be a
238
+ * superset of this or it is an unstyled app wearing a small byte count.
239
+ */
240
+ export const requiredCss = async (exportsMap: Record<string, unknown>): Promise<string> => {
241
+ const { modules } = await fixtureClosure(exportsMap);
242
+ const compiled = await compileStylesheet(`${HEAD}${TAIL}`);
243
+ const own = new Scanner({ sources: compiled.sources }).scan();
244
+ const contents = await Promise.all(
245
+ [...modules].sort().map(async (file) => ({
246
+ content: await Bun.file(file).text(),
247
+ extension: file.slice(file.lastIndexOf('.') + 1),
248
+ })),
249
+ );
250
+ const mine = new Scanner({}).scanFiles(contents);
251
+ return compiled.build([...new Set([...own, ...mine])].sort());
252
+ };
253
+
254
+ export { STYLES_BASE };
255
+
256
+ // ── the baseline ──────────────────────────────────────────────────────────────
257
+
258
+ export interface Baseline {
259
+ readonly header: string;
260
+ readonly counts: Map<string, number>;
261
+ }
262
+
263
+ export const ROUTES = ['union', 'areas'] as const;
264
+
265
+ export const parseBaseline = (text: string): Baseline => {
266
+ const header: string[] = [];
267
+ const counts = new Map<string, number>();
268
+ for (const line of text.split('\n')) {
269
+ if (line.startsWith('#') || line.trim() === '') {
270
+ if (counts.size === 0) header.push(line);
271
+ continue;
272
+ }
273
+ const [bytes, route] = line.trim().split(/\s+/);
274
+ if (bytes && route) counts.set(route, Number(bytes));
275
+ }
276
+ return { header: header.join('\n'), counts };
277
+ };
278
+
279
+ export const formatBaseline = (header: string, counts: Map<string, number>): string =>
280
+ `${header}\n${ROUTES.map((r) => `${String(counts.get(r) ?? 0).padStart(9)} ${r}`).join('\n')}\n`;
281
+
282
+ export const readBaseline = async (): Promise<Baseline> => {
283
+ const file = Bun.file(BASELINE);
284
+ return (await file.exists()) ? parseBaseline(await file.text()) : { header: '', counts: new Map() };
285
+ };
286
+
287
+ export interface Measurement {
288
+ readonly declared: string[];
289
+ readonly union: number;
290
+ readonly areas: number;
291
+ }
292
+
293
+ export const measure = async (): Promise<Measurement> => {
294
+ const declared = await declaredAreas();
295
+ const [union, areas] = await Promise.all([unionRouteCss(), areaRouteCss(declared)]);
296
+ return { declared, union: union.length, areas: areas.length };
297
+ };
298
+
299
+ if (import.meta.main) {
300
+ const { declared, union, areas } = await measure();
301
+ const baseline = await readBaseline();
302
+ const now = new Map<string, number>([
303
+ ['union', union],
304
+ ['areas', areas],
305
+ ]);
306
+ const saved = union - areas;
307
+ console.log(
308
+ `${FIXTURE} β€” ${declared.length} areas (${declared.join(', ')})\n` +
309
+ ` union route ${union.toLocaleString().padStart(9)} B\n` +
310
+ ` area route ${areas.toLocaleString().padStart(9)} B (βˆ’${saved.toLocaleString()} B, ` +
311
+ `${((saved / union) * 100).toFixed(1)}% smaller)`,
312
+ );
313
+ if (process.argv.includes('--prune')) {
314
+ const next = new Map(baseline.counts);
315
+ let lowered = 0;
316
+ for (const route of ROUTES) {
317
+ const recorded = baseline.counts.get(route);
318
+ const measured = now.get(route) as number;
319
+ if (recorded === undefined || measured < recorded) {
320
+ next.set(route, measured);
321
+ lowered++;
322
+ }
323
+ }
324
+ if (lowered === 0) {
325
+ console.log('\nnothing to prune β€” the baseline already records the smaller number.');
326
+ process.exit(0);
327
+ }
328
+ await Bun.write(BASELINE, formatBaseline(baseline.header, next));
329
+ console.log(`\nβœ… lowered ${lowered} baseline entry/entries in ${BASELINE}.`);
330
+ process.exit(0);
331
+ }
332
+ const grown = ROUTES.filter((r) => (now.get(r) as number) > (baseline.counts.get(r) ?? Number.POSITIVE_INFINITY));
333
+ if (grown.length > 0) {
334
+ console.error(
335
+ `\n❌ the fixture app's CSS GREW:\n` +
336
+ grown
337
+ .map((r) => ` ${r}: baseline allows ${baseline.counts.get(r)} B, it emits ${now.get(r)} B now`)
338
+ .join('\n') +
339
+ '\n Raise the number by hand, in a commit that says why β€” `--prune` only lowers.',
340
+ );
341
+ process.exit(1);
342
+ }
343
+ process.exit(0);
344
+ }
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Generate `src/styles-areas/*.css` β€” cursedbelt's utilities, PRECOMPILED PER AREA,
4
+ * so a consuming app pays for the components it renders instead of for the union.
5
+ *
6
+ * ── The number this exists to move ──────────────────────────────────────────
7
+ * `cursedbelt/styles.css` and the `styles-static.css` + `styles-utilities.css`
8
+ * pair emit the same union β€” the 135,470 B of `src/styles-utilities.css`,
9
+ * measured, whichever route an app takes β€” because `@source` cannot be aimed at
10
+ * anything finer than a directory and `VirtualList`/`VirtualCardGrid` live at the root of `src/react`.
11
+ * Five apps ship that union today; the heaviest of them draws a 3D orbit scene, a
12
+ * map frame, a lightbox and two lazy document viewers, and none of them renders
13
+ * more than a fraction of the design system.
14
+ *
15
+ * `scripts/styleAreas.ts` is the map and owns the WHY. This file is the compiler:
16
+ * one stylesheet per area, each holding the candidates in that area's import
17
+ * closure MINUS the ones `core` (and whatever the area declares in `needs`)
18
+ * already ships, so importing core plus five areas duplicates nothing. A consumer's stylesheet becomes:
19
+ *
20
+ * @import "tailwindcss";
21
+ * @import "cursedbelt/styles-static.css";
22
+ * @import "cursedbelt/styles-areas/core.css";
23
+ * @import "cursedbelt/styles-areas/data-table.css"; ← only what it renders
24
+ * @import "cursedbelt/theme.css";
25
+ *
26
+ * and it scans nothing of this package's source tree, exactly as the
27
+ * `styles-utilities.css` route already arranged.
28
+ *
29
+ * ── What it is worth, measured on the fixture app ───────────────────────────
30
+ * `demo/fixtures/lean-app` is a real consumer built out of this package's own
31
+ * deep subpaths: a releases console with stat tiles, a chart, a sortable table
32
+ * and a virtualized log. Compiled both ways (tailwindcss 4.3.3, unminified, in
33
+ * this repo, both counts including the app's own utilities and the theme):
34
+ *
35
+ * styles-static.css + styles-utilities.css (the union) 169,517 B
36
+ * styles-static.css + core + charts + data-table
37
+ * + stats + virtual 119,432 B βˆ’29.5%
38
+ *
39
+ * Of that, the cursedbelt half falls from 142,119 B to 92,034 B β€” a 35% cut, and
40
+ * the fixture is not a minimal app: it declares five of the 43 areas. `core`
41
+ * alone is 66,700 B against the 135,470 B union artifact, so the floor for an app
42
+ * that renders only primitives is roughly half of what every app ships today.
43
+ *
44
+ * πŸ”΄ The saving is bounded by how coarse `core` is, NOT by this mechanism. An area
45
+ * can be one file β€” `scripts/styleAreas.ts` scans a file LIST, so nothing here has
46
+ * a directory's granularity in it β€” and splitting `core` further is a table edit
47
+ * with a regenerate, not a redesign.
48
+ *
49
+ * `src/fixtureAppCss.spec.ts` is what holds that number down β€” a baseline that
50
+ * fails when the emitted CSS for the fixture GROWS, which is the check nothing in
51
+ * this package had while three, then four, then five apps paid for the union.
52
+ *
53
+ * ── The trap, and why nothing here is allowed to be hand-written ────────────
54
+ * Every failure this mechanism can have is SILENT in the same way the
55
+ * `styles-utilities.css` split was: a missing area, a stale artifact and a
56
+ * collapsed scan all produce CSS that is smaller, and smaller is what the whole
57
+ * exercise was asked for. So the area map is CLOSURE-derived rather than listed
58
+ * (an area cannot be missing what its own components import),
59
+ * `coverageFault()` fails on a module no area reaches, and
60
+ * `src/stylesAreas.spec.ts` proves core-plus-every-area emits the union rule for
61
+ * rule before any of it is allowed to ship.
62
+ *
63
+ * Usage:
64
+ * bun run styles:areas # write src/styles-areas/*.css
65
+ * bun run styles:areas --check # exit 1 if any is stale, write nothing
66
+ */
67
+ import { REPO_ROOT, STATIC_STYLESHEET, readStylesheets } from './generateStaticStyles';
68
+ import { UTILITY_INPUT, compileStylesheet } from './generateUtilityStyles';
69
+ import {
70
+ AREA_DIR,
71
+ AREAS,
72
+ type Area,
73
+ CORE,
74
+ areaClosures,
75
+ candidatesIn,
76
+ coverageFault,
77
+ transitiveNeeds,
78
+ } from './styleAreas';
79
+
80
+ export { AREA_DIR, AREAS, CORE };
81
+
82
+ /** The stylesheet an area is written to, relative to the repo root. */
83
+ export const areaFile = (name: string): string => `${AREA_DIR}/${name}.css`;
84
+
85
+ /** The export subpath a consumer imports it by. */
86
+ export const areaSubpath = (name: string): string => `./${AREA_DIR.replace(/^src\//, '')}/${name}.css`;
87
+
88
+ /**
89
+ * A floor on `core`'s scan, for the same reason `generateUtilityStyles.ts` has
90
+ * one: a collapsed scan compiles and writes an EMPTY stylesheet, and every app
91
+ * that imported it renders unstyled with nothing red anywhere. 5,170 candidates
92
+ * today; a tripwire, not a ratchet.
93
+ */
94
+ export const MIN_CORE_CANDIDATES = 1_000;
95
+
96
+ const banner = (area: Area, candidates: number, subtracted: boolean): string => {
97
+ const needs = transitiveNeeds(area.name);
98
+ const imports = [CORE, ...needs, ...(subtracted ? [area.name] : [])]
99
+ .filter((n, i, all) => all.indexOf(n) === i)
100
+ .map((n) => ` * @import "cursedbelt/styles-areas/${n}.css";`)
101
+ .join('\n');
102
+ return `/*
103
+ * cursedbelt β€” the \`${area.name}\` area. GENERATED BUILD ARTIFACT. Never hand-edit.
104
+ *
105
+ * ${area.why}.
106
+ *
107
+ * ${
108
+ subtracted
109
+ ? `Every Tailwind utility the modules in this area's import closure use, MINUS the\n * ones ${[CORE, ...needs].map((n) => `\`styles-areas/${n}.css\``).join(' and ')} already ship${needs.length > 0 ? '' : 's'}. πŸ”΄ Import ALL of these, in\n * this order β€” this file alone is incomplete:`
110
+ : 'Every Tailwind utility the primitives and their plumbing use. Import it first,\n * before any other area:'
111
+ }
112
+ *
113
+ * @import "tailwindcss";
114
+ * @import "cursedbelt/styles-static.css";
115
+ ${imports}
116
+ * @import "cursedbelt/theme.css";
117
+ *
118
+ * An app that imports every area gets exactly what \`cursedbelt/styles-utilities.css\`
119
+ * emits β€” proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
120
+ * when this file is stale. ${candidates} candidate(s) scanned.
121
+ *
122
+ * Written by scripts/generateAreaStyles.ts (\`bun run styles:areas\`), which
123
+ * \`bun run build\` runs. The map of what is in which area, and why the areas are
124
+ * import closures rather than a hand-written list, is scripts/styleAreas.ts.
125
+ */
126
+ `;
127
+ };
128
+
129
+ export interface BuiltArea {
130
+ readonly name: string;
131
+ readonly css: string;
132
+ /** Candidates this stylesheet was compiled from, after core was subtracted. */
133
+ readonly candidates: string[];
134
+ /** Every module in the area's closure, repo-relative β€” what the spec reports on. */
135
+ readonly modules: string[];
136
+ }
137
+
138
+ /**
139
+ * Compile every area.
140
+ *
141
+ * Refuses to run against a stale `styles-static.css` for the same reason
142
+ * `generateUtilityStyles.ts` does: these are compiled against it through
143
+ * `@reference`, so a stale one bakes in the wrong tokens with nothing to show for
144
+ * it in the output.
145
+ */
146
+ export const buildAreaStyles = async (): Promise<BuiltArea[]> => {
147
+ const { expected, onDisk } = await readStylesheets();
148
+ if (onDisk !== expected) {
149
+ throw new Error(
150
+ `${STATIC_STYLESHEET} is ${onDisk === null ? 'missing' : 'stale'}, and these artifacts are compiled\n` +
151
+ `against it (\`@reference\`), so they would bake in the wrong tokens. Run:\n` +
152
+ ` cd ${REPO_ROOT} && bun run styles:static`,
153
+ );
154
+ }
155
+ const { closures } = await areaClosures();
156
+ const fault = coverageFault(closures);
157
+ if (fault) throw new Error(fault);
158
+
159
+ const coreClosure = closures.get(CORE) as Set<string>;
160
+ const coreCandidates = await candidatesIn(coreClosure);
161
+ if (coreCandidates.size < MIN_CORE_CANDIDATES) {
162
+ throw new Error(
163
+ `the scan of \`${CORE}\` found ${coreCandidates.size} candidates (floor ${MIN_CORE_CANDIDATES}).\n` +
164
+ 'That is a collapsed scan, not a small core β€” the stylesheet it would write emits an\n' +
165
+ 'empty utility layer and every consuming app renders UNSTYLED.',
166
+ );
167
+ }
168
+
169
+ // Every area's raw candidate set first: `needs` subtracts one area from another,
170
+ // so no area can be compiled until all of them have been scanned.
171
+ const raw = new Map<string, Set<string>>([[CORE, coreCandidates]]);
172
+ for (const area of AREAS) {
173
+ if (area.name === CORE) continue;
174
+ raw.set(area.name, await candidatesIn(closures.get(area.name) as Set<string>));
175
+ }
176
+
177
+ const built: BuiltArea[] = [];
178
+ for (const area of AREAS) {
179
+ const closure = closures.get(area.name) as Set<string>;
180
+ const needs = transitiveNeeds(area.name);
181
+ const subtract =
182
+ area.name === CORE
183
+ ? new Set<string>()
184
+ : new Set([...coreCandidates, ...needs.flatMap((n) => [...(raw.get(n) as Set<string>)])]);
185
+ const only = [...(raw.get(area.name) as Set<string>)].filter((c) => !subtract.has(c)).sort();
186
+ // πŸ”΄ A fresh compile per area. The object `compile()` returns ACCUMULATES the
187
+ // candidates it has been built with, so reusing one would make every area after
188
+ // the first a superset of the ones before it β€” and each file would still look
189
+ // plausible on its own. Found by measuring; the symptom was areas growing
190
+ // monotonically in the order they were compiled.
191
+ const compiled = await compileStylesheet(UTILITY_INPUT);
192
+ built.push({
193
+ name: area.name,
194
+ css: `${banner(area, only.length, area.name !== CORE)}${compiled.build(only)}`,
195
+ candidates: only,
196
+ modules: [...closure].map((f) => f.replace(`${REPO_ROOT}/`, '')).sort(),
197
+ });
198
+ }
199
+ return built;
200
+ };
201
+
202
+ /** What each area's stylesheet should contain, and what is on disk right now. */
203
+ export const readAreaStyles = async (): Promise<Array<BuiltArea & { onDisk: string | null }>> => {
204
+ const built = await buildAreaStyles();
205
+ return Promise.all(
206
+ built.map(async (area) => {
207
+ const target = Bun.file(`${REPO_ROOT}/${areaFile(area.name)}`);
208
+ return { ...area, onDisk: (await target.exists()) ? await target.text() : null };
209
+ }),
210
+ );
211
+ };
212
+
213
+ /** Area stylesheets on disk that `AREAS` no longer names β€” a renamed area leaves one behind. */
214
+ export const strayAreaFiles = (): string[] => {
215
+ const known = new Set(AREAS.map((a) => `${a.name}.css`));
216
+ return [...new Bun.Glob('*.css').scanSync({ cwd: `${REPO_ROOT}/${AREA_DIR}`, onlyFiles: true })]
217
+ .filter((f) => !known.has(f))
218
+ .sort();
219
+ };
220
+
221
+ if (import.meta.main) {
222
+ const areas = await readAreaStyles();
223
+ const stale = areas.filter((a) => a.onDisk !== a.css);
224
+ const stray = strayAreaFiles();
225
+ if (process.argv.includes('--check')) {
226
+ if (stale.length === 0 && stray.length === 0) {
227
+ console.log(`βœ… ${AREA_DIR}/ is current (${areas.length} areas).`);
228
+ process.exit(0);
229
+ }
230
+ console.error(
231
+ `❌ ${AREA_DIR}/ is out of date:\n` +
232
+ stale.map((a) => ` ${a.onDisk === null ? 'missing' : 'stale'} ${areaFile(a.name)}`).join('\n') +
233
+ stray.map((f) => `\n no longer an area ${AREA_DIR}/${f}`).join('') +
234
+ `\n Run:\n cd ${REPO_ROOT} && bun run styles:areas`,
235
+ );
236
+ process.exit(1);
237
+ }
238
+ for (const area of stale) await Bun.write(`${REPO_ROOT}/${areaFile(area.name)}`, area.css);
239
+ const total = areas.reduce((sum, a) => sum + a.css.length, 0);
240
+ console.log(
241
+ stale.length === 0
242
+ ? `βœ… ${AREA_DIR}/ already current β€” ${areas.length} areas, ${total.toLocaleString()} B in total.`
243
+ : `βœ… wrote ${stale.length} of ${areas.length} area stylesheet(s) into ${AREA_DIR}/ ` +
244
+ `(${total.toLocaleString()} B in total; core is ${areas[0]?.css.length.toLocaleString()} B).`,
245
+ );
246
+ if (stray.length > 0) {
247
+ console.error(
248
+ `\n❌ ${AREA_DIR}/ still holds ${stray.length} stylesheet(s) no area names β€” delete them:\n ` +
249
+ stray.map((f) => `${REPO_ROOT}/${AREA_DIR}/${f}`).join('\n '),
250
+ );
251
+ process.exit(1);
252
+ }
253
+ }
@@ -45,6 +45,68 @@ describe('no-raw-action-button', () => {
45
45
  });
46
46
  });
47
47
 
48
+ // Filed 2026-09-15 after three false positives in three repos, all one defect: the tag
49
+ // walker stopped at the first `>` it saw, including one written in the PROSE of a comment
50
+ // inside the attribute list β€” which made every attribute below that point invisible, opt-out
51
+ // comment included. The third sighting was the fix for the second: a comment explaining this
52
+ // bug, containing a literal `>`, reproduced it.
53
+ describe('opening-tag extraction (comment-aware)', () => {
54
+ test('a prose "<select>" in a comment does not hide the opt-out below it', () => {
55
+ const source = `<button
56
+ // A radio-like list rather than a <select>, so the row is the control.
57
+ // guardrails-ignore no-raw-action-button: presentational row in a radio-like list
58
+ type="button"
59
+ >Move here</button>`;
60
+ expect(check('no-raw-action-button', source)).toHaveLength(0);
61
+ });
62
+
63
+ test('a prose angle bracket does not hide a role="radio" below it', () => {
64
+ const source = `<button
65
+ /* A radio-like list rather than a <select>. */
66
+ role="radio"
67
+ aria-checked={selected}
68
+ >Move here</button>`;
69
+ expect(check('no-raw-action-button', source)).toHaveLength(0);
70
+ });
71
+
72
+ test('an arrow-function attribute still does not end the tag', () => {
73
+ const source = `<button
74
+ onClick={() => setOpen(true)}
75
+ // guardrails-ignore no-raw-action-button: virtual gamepad key, a real button for AT
76
+ type="button"
77
+ >A</button>`;
78
+ expect(check('no-raw-action-button', source)).toHaveLength(0);
79
+ // …and the walker still finds the end of that tag, rather than running on.
80
+ expect(check('no-raw-action-button', '<button onClick={() => save()}>Save</button>')).toHaveLength(1);
81
+ });
82
+
83
+ test('a "//" inside an attribute string is a URL, not a comment', () => {
84
+ const source = '<button data-href="https://example.com/x">Go</button><button role="tab">Tab</button>';
85
+ expect(check('no-raw-action-button', source)).toHaveLength(1);
86
+ });
87
+
88
+ test('a comment does not extend one tag over the NEXT element\'s ignore', () => {
89
+ const source = `<button
90
+ // A radio-like list rather than a <select>.
91
+ type="button"
92
+ >One</button>
93
+ <button
94
+ // guardrails-ignore no-raw-action-button: presentational row
95
+ type="button"
96
+ >Two</button>`;
97
+ expect(check('no-raw-action-button', source)).toHaveLength(1);
98
+ });
99
+
100
+ test('the same defect on <input>: prose above the opt-out', () => {
101
+ const source = `<input
102
+ // A raw input rather than a <select>-backed Field β€” the native file picker.
103
+ // guardrails-ignore no-raw-inputs: native file picker, no Field equivalent
104
+ type="file"
105
+ />`;
106
+ expect(check('no-raw-inputs', source)).toHaveLength(0);
107
+ });
108
+ });
109
+
48
110
  describe('no-raw-overflow', () => {
49
111
  test('flags inline style overflow: auto', () => {
50
112
  expect(check('no-raw-overflow', '<div style={{ overflow: "auto" }} />')).toHaveLength(1);