cursedbelt 3.0.1 → 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 +288 -202
  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,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);
@@ -147,7 +147,7 @@
147
147
  // run, so these stay few and visible. A malformed/unrecognized-category directive is
148
148
  // ignored (its violations still fire) and a warning is printed.
149
149
 
150
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
150
+ import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
151
151
  import { basename, extname, join, relative } from "node:path";
152
152
 
153
153
  const IGNORE_DIR_NAMES = new Set(["node_modules", "dist", "dist-deploy", ".git", "build", "coverage"]);
@@ -179,7 +179,15 @@ interface Rule {
179
179
  }
180
180
 
181
181
  export function walk(dir: string, out: string[] = []): string[] {
182
- let entries: ReturnType<typeof readdirSync>;
182
+ // 🔴 `Dirent[]`, never `ReturnType<typeof readdirSync>`. `readdirSync` is OVERLOADED, and
183
+ // `ReturnType` of an overloaded function is the LAST overload — which @types/node 24 made
184
+ // `Dirent<NonSharedBuffer>[]` when `Dirent` became generic, while the call below returns
185
+ // the string-encoding overload's `Dirent<string>[]`. The annotation was always a guess at
186
+ // an overload set somebody else maintains; a release reordered it and the guess became
187
+ // five compile errors that every consumer of `cursedbelt/guardrails` inherited (task 172).
188
+ // Bare `Dirent` is `Dirent<string>` under >=24's default type argument and is the plain
189
+ // non-generic class before that, so this spelling is correct on both.
190
+ let entries: Dirent[];
183
191
  try {
184
192
  entries = readdirSync(dir, { withFileTypes: true });
185
193
  } catch {
@@ -201,13 +209,69 @@ export function walk(dir: string, out: string[] = []): string[] {
201
209
  return out;
202
210
  }
203
211
 
212
+ /** Index just past the string/template literal opening at `start`. Quoted attribute values
213
+ * are skipped so a `//` inside one reads as the URL it is (`src="https://…"`) rather than
214
+ * as the start of a comment. `'`/`"` stop at a newline — an unterminated quote must not
215
+ * swallow the rest of the file; a backtick may span lines and nests `${…}`. */
216
+ function skipQuoted(source: string, start: number): number {
217
+ const quote = source[start];
218
+ let i = start + 1;
219
+ if (quote !== "`") {
220
+ while (i < source.length && source[i] !== quote && source[i] !== "\n") {
221
+ i += source[i] === "\\" ? 2 : 1;
222
+ }
223
+ return Math.min(i + 1, source.length);
224
+ }
225
+ let depth = 0;
226
+ while (i < source.length) {
227
+ if (source[i] === "\\") {
228
+ i += 2;
229
+ continue;
230
+ }
231
+ if (depth === 0 && source[i] === "`") return i + 1;
232
+ if (source.slice(i, i + 2) === "${") {
233
+ depth++;
234
+ i += 2;
235
+ continue;
236
+ }
237
+ if (depth > 0 && source[i] === "}") depth--;
238
+ i++;
239
+ }
240
+ return i;
241
+ }
242
+
204
243
  /** Slice from a `<button` (or similar) match to the end of its opening tag, tolerating
205
- * `{...}` JS expressions in attribute values that may themselves contain `>`. */
244
+ * `{...}` JS expressions in attribute values that may themselves contain `>`
245
+ * (`onClick={() => …}`, which works because that `>` sits inside braces).
246
+ *
247
+ * JSX permits line and block comments INSIDE an attribute list, and a comment is prose —
248
+ * so it may contain an angle bracket ("a radio-like list rather than a `<select>`").
249
+ * Those are skipped the same way `blankComments` skips them, because a `>` written in prose
250
+ * used to end the tag here and make every attribute below it invisible — the
251
+ * `guardrails-ignore` opt-out included. `blankComments` cannot simply be run first: the
252
+ * opt-out checks read this slice and must see the ORIGINAL comment text (see the
253
+ * no-raw-action-button check below), so the skip only suppresses TERMINATION; the comment
254
+ * stays in what is returned. */
206
255
  function extractOpeningTag(source: string, startIndex: number): string {
207
256
  let i = startIndex;
208
257
  let braceDepth = 0;
209
258
  while (i < source.length) {
259
+ const two = source.slice(i, i + 2);
260
+ if (two === "//") {
261
+ const end = source.indexOf("\n", i);
262
+ i = end === -1 ? source.length : end;
263
+ continue;
264
+ }
265
+ if (two === "/*") {
266
+ const end = source.indexOf("*/", i + 2);
267
+ i = end === -1 ? source.length : end + 2;
268
+ continue;
269
+ }
210
270
  const ch = source[i];
271
+ if (ch === '"' || ch === "'" || ch === "`") {
272
+ i = skipQuoted(source, i);
273
+ continue;
274
+ }
211
275
  if (ch === "{") braceDepth++;
212
276
  else if (ch === "}") braceDepth--;
213
277
  else if (ch === ">" && braceDepth <= 0) {