cursedbelt 5.0.1 → 5.2.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/dist/react/engagement/beacon.d.ts +38 -0
  2. package/dist/react/engagement/beacon.d.ts.map +1 -0
  3. package/dist/react/engagement/beacon.js +62 -0
  4. package/dist/react/engagement/beacon.js.map +1 -0
  5. package/dist/react/engagement/beaconClient.d.ts +119 -0
  6. package/dist/react/engagement/beaconClient.d.ts.map +1 -0
  7. package/dist/react/engagement/beaconClient.js +203 -0
  8. package/dist/react/engagement/beaconClient.js.map +1 -0
  9. package/dist/react/engagement/index.d.ts +3 -0
  10. package/dist/react/engagement/index.d.ts.map +1 -0
  11. package/dist/react/engagement/index.js +5 -0
  12. package/dist/react/engagement/index.js.map +1 -0
  13. package/dist/react/lib/directUpload.d.ts +130 -0
  14. package/dist/react/lib/directUpload.d.ts.map +1 -0
  15. package/dist/react/lib/directUpload.js +228 -0
  16. package/dist/react/lib/directUpload.js.map +1 -0
  17. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  18. package/dist/react/media/FocusViewer.js.map +1 -1
  19. package/dist/react/media/MediaThumb.d.ts.map +1 -1
  20. package/dist/react/media/MediaThumb.js.map +1 -1
  21. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  22. package/dist/react/media/VideoTurner.js.map +1 -1
  23. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  24. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  25. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  26. package/dist/styles-areas/activity-bar.css +8 -11
  27. package/dist/styles-areas/analytics.css +30 -653
  28. package/dist/styles-areas/auth.css +10 -135
  29. package/dist/styles-areas/calendar.css +22 -397
  30. package/dist/styles-areas/chart-table.css +8 -20
  31. package/dist/styles-areas/charts.css +9 -70
  32. package/dist/styles-areas/chat.css +17 -345
  33. package/dist/styles-areas/clipboard.css +7 -7
  34. package/dist/styles-areas/code-editor.css +8 -111
  35. package/dist/styles-areas/companion-link.css +8 -51
  36. package/dist/styles-areas/core.css +131 -2488
  37. package/dist/styles-areas/dashboard-grid.css +10 -65
  38. package/dist/styles-areas/data-table.css +30 -638
  39. package/dist/styles-areas/deep-link.css +7 -7
  40. package/dist/styles-areas/diff-viewer.css +8 -11
  41. package/dist/styles-areas/disk-usage.css +13 -243
  42. package/dist/styles-areas/emoji.css +9 -135
  43. package/dist/styles-areas/fields.css +32 -563
  44. package/dist/styles-areas/file-tree.css +8 -14
  45. package/dist/styles-areas/filter-rail.css +14 -216
  46. package/dist/styles-areas/folder-tree.css +12 -200
  47. package/dist/styles-areas/keep-awake.css +7 -7
  48. package/dist/styles-areas/layout-engine.css +12 -220
  49. package/dist/styles-areas/markdown.css +9 -68
  50. package/dist/styles-areas/master-detail.css +14 -220
  51. package/dist/styles-areas/master-lock.css +9 -113
  52. package/dist/styles-areas/media-gallery.css +15 -332
  53. package/dist/styles-areas/media.css +20 -623
  54. package/dist/styles-areas/nav.css +24 -469
  55. package/dist/styles-areas/notifications.css +12 -226
  56. package/dist/styles-areas/overlays.css +14 -114
  57. package/dist/styles-areas/palette.css +15 -121
  58. package/dist/styles-areas/pdf-viewer.css +8 -28
  59. package/dist/styles-areas/problem-detail.css +9 -78
  60. package/dist/styles-areas/rich-text.css +9 -148
  61. package/dist/styles-areas/sharing.css +13 -134
  62. package/dist/styles-areas/spreadsheet.css +8 -18
  63. package/dist/styles-areas/stats.css +19 -262
  64. package/dist/styles-areas/test-report.css +13 -114
  65. package/dist/styles-areas/virtual.css +8 -11
  66. package/dist/styles-areas/wizard.css +21 -362
  67. package/dist/styles-areas/workbench.css +27 -524
  68. package/dist/styles-areas/workbook-viewer.css +8 -25
  69. package/dist/styles-static.css +1 -0
  70. package/dist/styles.css +1 -0
  71. package/package.json +20 -2
  72. package/scripts/checkAreaStyles.spec.ts +158 -1
  73. package/scripts/checkAreaStyles.ts +226 -6
  74. package/scripts/cssRules.ts +8 -1
  75. package/scripts/gate.ts +1 -1
  76. package/scripts/generateAreaStyles.ts +130 -10
  77. package/scripts/paths.ts +1 -1
  78. package/scripts/styleAreas.ts +2 -0
  79. package/src/react/components/ActionBar.spec.tsx +1 -0
  80. package/src/react/engagement/beacon.tsx +63 -0
  81. package/src/react/engagement/beaconClient.spec.ts +138 -0
  82. package/src/react/engagement/beaconClient.ts +228 -0
  83. package/src/react/engagement/index.ts +13 -0
  84. package/src/react/file-tree/FileTree.tsx +3 -3
  85. package/src/react/lib/directUpload.spec.ts +128 -0
  86. package/src/react/lib/directUpload.ts +334 -0
  87. package/src/react/media/FocusViewer.tsx +1 -0
  88. package/src/react/media/MediaThumb.tsx +0 -1
  89. package/src/react/media/VideoTurner.tsx +0 -1
  90. package/src/react/media-gallery/GalleryTable.tsx +0 -1
  91. package/src/react/media-gallery/MediaGallery.tsx +0 -1
  92. package/src/styles-areas/activity-bar.css +8 -11
  93. package/src/styles-areas/analytics.css +30 -653
  94. package/src/styles-areas/auth.css +10 -135
  95. package/src/styles-areas/calendar.css +22 -397
  96. package/src/styles-areas/chart-table.css +8 -20
  97. package/src/styles-areas/charts.css +9 -70
  98. package/src/styles-areas/chat.css +17 -345
  99. package/src/styles-areas/clipboard.css +7 -7
  100. package/src/styles-areas/code-editor.css +8 -111
  101. package/src/styles-areas/companion-link.css +8 -51
  102. package/src/styles-areas/core.css +131 -2488
  103. package/src/styles-areas/dashboard-grid.css +10 -65
  104. package/src/styles-areas/data-table.css +30 -638
  105. package/src/styles-areas/deep-link.css +7 -7
  106. package/src/styles-areas/diff-viewer.css +8 -11
  107. package/src/styles-areas/disk-usage.css +13 -243
  108. package/src/styles-areas/emoji.css +9 -135
  109. package/src/styles-areas/fields.css +32 -563
  110. package/src/styles-areas/file-tree.css +8 -14
  111. package/src/styles-areas/filter-rail.css +14 -216
  112. package/src/styles-areas/folder-tree.css +12 -200
  113. package/src/styles-areas/keep-awake.css +7 -7
  114. package/src/styles-areas/layout-engine.css +12 -220
  115. package/src/styles-areas/markdown.css +9 -68
  116. package/src/styles-areas/master-detail.css +14 -220
  117. package/src/styles-areas/master-lock.css +9 -113
  118. package/src/styles-areas/media-gallery.css +15 -332
  119. package/src/styles-areas/media.css +20 -623
  120. package/src/styles-areas/nav.css +24 -469
  121. package/src/styles-areas/notifications.css +12 -226
  122. package/src/styles-areas/overlays.css +14 -114
  123. package/src/styles-areas/palette.css +15 -121
  124. package/src/styles-areas/pdf-viewer.css +8 -28
  125. package/src/styles-areas/problem-detail.css +9 -78
  126. package/src/styles-areas/rich-text.css +9 -148
  127. package/src/styles-areas/sharing.css +13 -134
  128. package/src/styles-areas/spreadsheet.css +8 -18
  129. package/src/styles-areas/stats.css +19 -262
  130. package/src/styles-areas/test-report.css +13 -114
  131. package/src/styles-areas/virtual.css +8 -11
  132. package/src/styles-areas/wizard.css +21 -362
  133. package/src/styles-areas/workbench.css +27 -524
  134. package/src/styles-areas/workbook-viewer.css +8 -25
  135. package/src/styles-static.css +1 -0
  136. package/src/styles.css +1 -0
  137. package/src/stylesAreas.spec.ts +12 -7
@@ -2,7 +2,18 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { checkAreaStyles, classesInCss, readBeltSheets, tokensInJs } from './checkAreaStyles';
5
+ import {
6
+ cascadeFaults,
7
+ checkAreaStyles,
8
+ classesInCss,
9
+ hasVariantPrefix,
10
+ inlineCandidatesIn,
11
+ onlyAsPropertyName,
12
+ onlyInMergeTables,
13
+ readBeltSheets,
14
+ tokensInJs,
15
+ } from './checkAreaStyles';
16
+ import { compileStylesheet } from './generateUtilityStyles';
6
17
 
7
18
  const BELT = {
8
19
  union: `@layer utilities { .flex { display: flex } .rounded-full { border-radius: 9999px } .hover\\:bg-muted { &:hover { @media (hover: hover) { background: var(--muted) } } } .recharts-tip { opacity: .5; } }`,
@@ -19,6 +30,26 @@ describe('classesInCss', () => {
19
30
  expect([...names].sort()).toEqual(['2xl:grid', 'a', 'hover:bg-muted', 'w-[1.5rem]']);
20
31
  });
21
32
 
33
+ test('🔴 a MARKER class (`peer`, `group`) that only appears inside :where()/:is() has no rule of its own', () => {
34
+ const css = `.peer-focus\\:ring-2:is(:where(.peer):focus ~ *){a:b} .group-hover\\:x:is(:where(.group\\/row):hover *){c:d} :where(.space-y-1>:not(:last-child)){e:f}`;
35
+ const names = classesInCss(css);
36
+ expect(names.has('peer')).toBe(false);
37
+ expect(names.has('group')).toBe(false);
38
+ expect(names.has('group/row')).toBe(false);
39
+ expect(names.has('peer-focus:ring-2')).toBe(true);
40
+ // …while a REAL utility Tailwind writes inside :where() is still a class with a rule.
41
+ expect(names.has('space-y-1')).toBe(true);
42
+ });
43
+
44
+ test("🔴 `peer` in an app's JS demands NO area — the real union and areas (apps/family, 2026-09-23)", () => {
45
+ const belt = readBeltSheets();
46
+ const { missing } = checkAreaStyles({ js: ['<input class="peer"/>'], css: [''] }, belt);
47
+ expect(missing.filter((m) => m.className === 'peer')).toEqual([]);
48
+ // …while a real workbench-only class still does, so the check is still looking.
49
+ const real = checkAreaStyles({ js: ['peer-focus:ring-2'], css: [''] }, belt).missing;
50
+ expect(real.map((m) => m.className)).toContain('peer-focus:ring-2');
51
+ });
52
+
22
53
  test("reads the real union — cursedbelt's own styles-utilities.css", () => {
23
54
  const { union, areas } = readBeltSheets();
24
55
  const names = classesInCss(union);
@@ -69,3 +100,129 @@ describe('checkAreaStyles', () => {
69
100
  }
70
101
  });
71
102
  });
103
+
104
+ /** tailwind-merge's class-group table, as `cn()` ships it in a built bundle (flix, 2026-09-23). */
105
+ const MERGE_TABLE =
106
+ '"text-decoration":["underline","overline","line-through","no-underline"],' +
107
+ '"font-style":["italic","not-italic"],"fvn-ordinal":["ordinal"],"divide-x":[{"divide-x":le()}],' +
108
+ // flix 5.1.0 build: a group mixing names with minified helper references.
109
+ // (verbatim shape: the minifier drops the quotes from identifier keys)
110
+ 'cursor:[{cursor:["auto","nwse-resize","zoom-in","zoom-out",fe,ue]}]';
111
+
112
+ describe('🔴 tailwind-merge tables are not usage (task 2118)', () => {
113
+ test('a token ONLY in the table is excused; a group key is excused', () => {
114
+ expect(onlyInMergeTables('line-through', MERGE_TABLE)).toBe(true);
115
+ expect(onlyInMergeTables('not-italic', MERGE_TABLE)).toBe(true);
116
+ expect(onlyInMergeTables('divide-x', MERGE_TABLE)).toBe(true);
117
+ });
118
+
119
+ test('refused when the token is ALSO in a rendered class string, or in an unkeyed array, or absent', () => {
120
+ expect(onlyInMergeTables('line-through', `${MERGE_TABLE};x("flex line-through text-sm")`)).toBe(false);
121
+ expect(onlyInMergeTables('ring-inset', 'x=["ring-inset"]')).toBe(false);
122
+ expect(onlyInMergeTables('ring-inset', '')).toBe(false);
123
+ // An array holding a CALL is not a table.
124
+ expect(onlyInMergeTables('zoom-in', '"cursor":["zoom-in",f()]')).toBe(false);
125
+ });
126
+
127
+ test('the flix shape reports NO miss for table-only tokens and `peer`, and still reports `right-3`', () => {
128
+ const belt = readBeltSheets();
129
+ const js = [`${MERGE_TABLE};const a=x("peer");const b={className:"absolute right-3 top-2"};`];
130
+ const { missing } = checkAreaStyles({ js, css: ['.absolute{position:absolute}.top-2{top:.5rem}'] }, belt);
131
+ const names = missing.map((m) => m.className);
132
+ for (const excused of ['line-through', 'not-italic', 'ordinal', 'divide-x', 'no-underline', 'zoom-in', 'peer']) expect(names).not.toContain(excused);
133
+ expect(names).toContain('right-3');
134
+ });
135
+ });
136
+
137
+ describe('🔴 a property NAME is not class usage (task 2131, station)', () => {
138
+ // Verbatim from station's build: d3's scale check, inside recharts.
139
+ const STATION = 'function RE(e){if(e!=null)return"invert"in e&&typeof e.invert=="function"?e.invert:null}';
140
+
141
+ test('an `in` test and an object key are excused', () => {
142
+ expect(onlyAsPropertyName('invert', STATION)).toBe(true);
143
+ expect(onlyAsPropertyName('invert', 'const o={"invert":1}')).toBe(true);
144
+ });
145
+
146
+ test('refused when the token is ALSO a class, or a lone string value (`cn("italic")` IS usage)', () => {
147
+ expect(onlyAsPropertyName('invert', `${STATION};x({className:"invert grayscale"})`)).toBe(false);
148
+ expect(onlyAsPropertyName('italic', 'x=cn("italic",y)')).toBe(false);
149
+ expect(onlyAsPropertyName('h-8', 'const v={size:{sm:"h-8"}}')).toBe(false);
150
+ expect(onlyAsPropertyName('invert', '')).toBe(false);
151
+ });
152
+
153
+ test('the station shape reports no `invert` miss', () => {
154
+ const { missing } = checkAreaStyles({ js: [STATION], css: [''] }, readBeltSheets());
155
+ expect(missing.map((m) => m.className)).not.toContain('invert');
156
+ });
157
+ });
158
+
159
+ describe('an area is `@source inline` candidates since 5.1.0 (task 2124)', () => {
160
+ test('inlineCandidatesIn reads both quote kinds and skips markers', () => {
161
+ const css = `/* banner */\n@source inline("flex sm:hidden peer");\n@source inline('before:content-[""]');\n`;
162
+ expect([...inlineCandidatesIn(css)].sort()).toEqual(['before:content-[""]', 'flex', 'sm:hidden']);
163
+ });
164
+
165
+ test('the shipped areas carry their utilities as candidates, and the check can still name an area', () => {
166
+ const belt = readBeltSheets();
167
+ expect(inlineCandidatesIn(belt.areas.core ?? '').size).toBeGreaterThan(400);
168
+ const { missing } = checkAreaStyles({ js: ['x("truncate")'], css: [''] }, belt);
169
+ expect(missing).toEqual([{ className: 'truncate', area: 'core' }]);
170
+ });
171
+ });
172
+
173
+ describe('🔴 the ORDER check — a base utility after a variant that sets the same property (task 2124)', () => {
174
+ test('flix @87ba751: its own `sm:flex` pass, then core.css\'s precompiled `.hidden` — RED', () => {
175
+ // The two offsets the task measured in dist/assets/index-*.css, verbatim in shape.
176
+ const built =
177
+ '@layer utilities{@media(min-width:40rem){.sm\\:flex{display:flex}.sm\\:hidden{display:none}}' +
178
+ '.flex{display:flex}.grid{display:grid}.hidden{display:none}}';
179
+ const faults = cascadeFaults([built]);
180
+ expect(faults).toContainEqual({ base: 'hidden', variant: 'sm:flex', properties: ['display'] });
181
+ });
182
+
183
+ test("G2's fixture: `.md:p-8` then `.p-4` — RED", () => {
184
+ const faults = cascadeFaults(['@layer utilities{@media (width>=48rem){.md\\:p-8{padding:2rem}}.p-4{padding:1rem}}']);
185
+ expect(faults.map((f) => `${f.base}>${f.variant}`)).toEqual(['p-4>md:p-8']);
186
+ });
187
+
188
+ test('ONE Tailwind pass never has a fault — including .container, divide-y and arbitrary properties', async () => {
189
+ const one = (await compileStylesheet('@import "tailwindcss" source(none);\n@import "./styles-static.css";\n@import "./theme.css";\n')).build([
190
+ 'hidden', 'sm:flex', 'p-4', 'md:p-8', 'container', 'max-w-sm', 'divide-y', 'border-t', 'break-words',
191
+ '[overflow-wrap:anywhere]', 'text-[color:var(--accent)]', 'text-sm', 'hover:bg-muted', 'bg-muted',
192
+ ]);
193
+ expect(cascadeFaults([one])).toEqual([]);
194
+ });
195
+
196
+ test('🔴 the structural fix: app + areas compile as ONE pass (5.1.0), where precompiled areas were two (≤5.0.x)', async () => {
197
+ // The app's own `hidden sm:flex` (flix's header) and cursedbelt's core, both ways.
198
+ const app = '@import "tailwindcss" source(none);\n@source inline("hidden sm:flex sm:hidden flex");\n@import "./styles-static.css";\n';
199
+ const onePass = (await compileStylesheet(`${app}@import "./styles-areas/core.css";\n@import "./theme.css";\n`)).build([]);
200
+ expect(cascadeFaults([onePass])).toEqual([]);
201
+ // What ≤5.0.x shipped: core compiled on its own and appended after the app's pass.
202
+ const core = [...inlineCandidatesIn(readBeltSheets().areas.core ?? '')];
203
+ const precompiledCore = (await compileStylesheet('@import "tailwindcss/utilities.css" layer(utilities);\n@reference "./styles-static.css";\n@reference "./theme.css";\n')).build(core);
204
+ const appPass = (await compileStylesheet(`${app}@import "./theme.css";\n`)).build([]);
205
+ expect(cascadeFaults([`${appPass}\n${precompiledCore}`]).some((f) => f.variant === 'sm:flex' && f.base === 'hidden')).toBe(true);
206
+ });
207
+
208
+ test('hasVariantPrefix: only a colon outside brackets is a variant', () => {
209
+ expect(hasVariantPrefix('sm:flex')).toBe(true);
210
+ expect(hasVariantPrefix('[&>*]:w-full')).toBe(true);
211
+ expect(hasVariantPrefix('[overflow-wrap:anywhere]')).toBe(false);
212
+ expect(hasVariantPrefix('text-[color:var(--x)]')).toBe(false);
213
+ });
214
+
215
+ test('the CLI exits 1 on an out-of-order build even when every class is styled', () => {
216
+ const dir = mkdtempSync(join(tmpdir(), 'belt-order-check-'));
217
+ try {
218
+ mkdirSync(join(dir, 'assets'));
219
+ writeFileSync(join(dir, 'assets', 'index.js'), 'const c="truncate";');
220
+ writeFileSync(join(dir, 'assets', 'index.css'), '@layer utilities{@media(min-width:40rem){.sm\\:flex{display:flex}}.truncate{overflow:hidden}.hidden{display:none}}');
221
+ const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
222
+ expect(r.exitCode).toBe(1);
223
+ expect(r.stderr.toString()).toContain('.hidden (later) overrides .sm:flex');
224
+ } finally {
225
+ rmSync(dir, { recursive: true, force: true });
226
+ }
227
+ });
228
+ });
@@ -25,6 +25,10 @@
25
25
  * costs a broken screen on a live hostname. A class that appears in NO area is excluded (the
26
26
  * area split already proves core-plus-every-area equals the union, `src/stylesAreas.spec.ts`).
27
27
  *
28
+ * ── …and whether the build's utilities are in ONE order (5.1.0, task 2124) ──────────────
29
+ * {@link cascadeFaults}: a base utility after a variant that sets the same property is two sorted
30
+ * Tailwind passes concatenated, and the base wins at every width. Both halves exit 1.
31
+ *
28
32
  * Usage, from an app, after its build:
29
33
  * bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist
30
34
  * Exit 0 = complete, 1 = a class is unstyled (listed with its area), 2 = could not measure
@@ -47,7 +51,16 @@ function unescapeIdent(raw: string): string {
47
51
  );
48
52
  }
49
53
 
50
- /** Every class selector in a stylesheet, unescaped. Comments are dropped first. */
54
+ /**
55
+ * 🔴 MARKER classes: `peer`, `group` and their named forms (`group/row`). Tailwind reads them
56
+ * only inside another class's selector (`.peer-focus\:ring-2:is(:where(.peer):focus ~ *)`); the
57
+ * stylesheet has no rule FOR them, so no area carries one and none may be demanded for one.
58
+ * Measured 2026-09-23 (task 2118): `.peer` sits only inside `workbench.css`'s `peer-focus:` rules,
59
+ * so every app writing `class="peer"` was told to import the whole `workbench` area.
60
+ */
61
+ export const isMarkerClass = (name: string): boolean => /^(peer|group)(\/[\w-]+)?$/.test(name);
62
+
63
+ /** Every class a stylesheet names in a selector, unescaped — except the {@link isMarkerClass} ones. */
51
64
  export function classesInCss(css: string): Set<string> {
52
65
  const out = new Set<string>();
53
66
  const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
@@ -56,17 +69,208 @@ export function classesInCss(css: string): Set<string> {
56
69
  for (const m of selector.matchAll(/\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[A-Za-z0-9_-])+)/g)) {
57
70
  const name = unescapeIdent(m[1] as string);
58
71
  if (/^[0-9]/.test(name) && !/[a-z]/i.test(name)) continue; // `.5` in a stray number
72
+ if (isMarkerClass(name)) continue;
59
73
  out.add(name);
60
74
  }
61
75
  }
62
76
  return out;
63
77
  }
64
78
 
79
+ /**
80
+ * The candidates an area stylesheet names in `@source inline("…")` / `@source inline('…')` —
81
+ * what an area IS since 5.1.0 (task 2124): candidates the app's own Tailwind pass generates,
82
+ * not precompiled rules. Brace expansion is not used by the generator, so none is done here.
83
+ */
84
+ export function inlineCandidatesIn(css: string): Set<string> {
85
+ const out = new Set<string>();
86
+ for (const m of css.matchAll(/@source\s+inline\(\s*(?:"([^"]*)"|'([^']*)')\s*\)/g)) {
87
+ for (const token of (m[1] ?? m[2] ?? '').split(/\s+/)) if (token && !isMarkerClass(token)) out.add(token);
88
+ }
89
+ return out;
90
+ }
91
+
65
92
  /** Every whitespace/quote/backtick-delimited token in a JS bundle. */
66
93
  export function tokensInJs(js: string): Set<string> {
67
94
  return new Set(js.split(/[\s"'`]+/).filter((t) => t.length > 0 && t.length < 200));
68
95
  }
69
96
 
97
+ // ── tailwind-merge's tables are not class usage (5.1.0, task 2118) ───────────────────────
98
+
99
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100
+
101
+ /**
102
+ * Does `token` occur in the built JS ONLY as an entry of tailwind-merge's class-group table —
103
+ * `"text-decoration":["underline","overline","line-through","no-underline"]` — or as a group's own
104
+ * key (`"divide-x":[{…}]`)? `cn()` ships that table in every bundle, so tokenising it reported
105
+ * `line-through`, `not-italic`, `ordinal`, `ring-inset`, `zoom-in`, `divide-x`, `normal-case` and
106
+ * `no-underline` as used on flix and demanded seven areas it never renders (~3 kB gzip, measured
107
+ * 2026-09-23). Lifted from `apps/music/scripts/check-area-styles.ts`, with its tests.
108
+ *
109
+ * 🔴 Refuses whenever the token ALSO appears bare inside any longer string — a real className —
110
+ * and when it appears in an array that is not a keyed table entry. A filter that could only say
111
+ * "fine" would be the check going green on nothing.
112
+ */
113
+ export function onlyInMergeTables(token: string, js: string): boolean {
114
+ const quoted = new RegExp(`"${escapeRe(token)}"`, 'g');
115
+ let seen = 0;
116
+ for (const m of js.matchAll(quoted)) {
117
+ seen++;
118
+ const at = m.index ?? 0;
119
+ if (js[at + m[0].length] === ':') continue; // a group's own NAME, as an object key
120
+ const open = js.lastIndexOf('[', at);
121
+ const close = js.indexOf(']', at);
122
+ if (open < 0 || close < 0) return false;
123
+ const inside = js.slice(open + 1, close);
124
+ // Quoted names, and the minified helper references tailwind-merge mixes into a group
125
+ // (`"cursor":[…,"zoom-in","zoom-out",fe,ue]` on flix) — never an expression or a call.
126
+ const ELEMENT = '(?:"[^"]*"|[A-Za-z_$][\\w$]*)';
127
+ if (!new RegExp(`^\\s*${ELEMENT}(\\s*,\\s*${ELEMENT})*\\s*$`).test(inside)) return false;
128
+ // Keyed — `"divide-x":[` or, once a minifier drops the quotes, `cursor:[`.
129
+ if (!/(?:"[\w-]+"|[A-Za-z_$][\w$]*)\s*:\s*$/.test(js.slice(Math.max(0, open - 64), open))) return false;
130
+ }
131
+ const bare = new RegExp(`(^|[\\s"'\`])${escapeRe(token)}(?=[\\s"'\`]|$)`, 'g');
132
+ return seen > 0 && [...js.matchAll(bare)].length === seen;
133
+ }
134
+
135
+ /**
136
+ * Does `token` occur in the built JS only as a PROPERTY NAME — the left side of an `in` test
137
+ * (`"invert"in e`, d3's scale check inside recharts) or an object key (`{"invert":…}`)? A className
138
+ * is always a string VALUE, never either (task 2131, measured on station's build 2026-09-23: the
139
+ * only false reference left after the tailwind-merge filter, and it demanded the `media` area).
140
+ * Refused as soon as the token also appears in any class string.
141
+ */
142
+ export function onlyAsPropertyName(token: string, js: string): boolean {
143
+ const quoted = new RegExp(`(["'])${escapeRe(token)}\\1`, 'g');
144
+ let seen = 0;
145
+ for (const m of js.matchAll(quoted)) {
146
+ seen++;
147
+ const after = js.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 4);
148
+ if (!/^\s*(?::|in\b)/.test(after)) return false;
149
+ }
150
+ const bare = new RegExp(`(^|[\\s"'\`])${escapeRe(token)}(?=[\\s"'\`]|$)`, 'g');
151
+ return seen > 0 && [...js.matchAll(bare)].length === seen;
152
+ }
153
+
154
+ // ── the ORDER check (5.1.0, task 2124) ─────────────────────────────────────────────────
155
+
156
+ export interface CascadeFault {
157
+ /** The base utility that comes LATER, and so wins at every width. */
158
+ base: string;
159
+ /** The variant it silently overrides. */
160
+ variant: string;
161
+ /** The properties both set. */
162
+ properties: string[];
163
+ }
164
+
165
+ interface UtilityRule {
166
+ cls: string;
167
+ variant: boolean;
168
+ properties: Set<string>;
169
+ }
170
+
171
+ /** A `:` OUTSIDE brackets: `sm:flex` is a variant, `[overflow-wrap:anywhere]` and `text-[color:red]` are not. */
172
+ export function hasVariantPrefix(cls: string): boolean {
173
+ let depth = 0;
174
+ for (const ch of cls) {
175
+ if (ch === '[' || ch === '(') depth++;
176
+ else if (ch === ']' || ch === ')') depth = Math.max(0, depth - 1);
177
+ else if (ch === ':' && depth === 0) return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /** Every rule inside `@layer utilities`, in document order: its class, whether it is a variant, what it sets. */
183
+ function utilityRules(css: string): UtilityRule[] {
184
+ const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
185
+ const out: UtilityRule[] = [];
186
+ const stack: { header: string; props: Set<string> }[] = [];
187
+ let buf = '';
188
+ const flushDecls = (text: string) => {
189
+ const top = stack[stack.length - 1];
190
+ if (!top) return;
191
+ for (const decl of text.split(';')) {
192
+ const m = /^\s*([-\w]+)\s*:/.exec(decl);
193
+ if (m?.[1] && !m[1].startsWith('--')) top.props.add(m[1]);
194
+ }
195
+ };
196
+ for (let i = 0; i < code.length; i++) {
197
+ const ch = code[i];
198
+ if (ch === '"' || ch === "'") {
199
+ const end = code.indexOf(ch, i + 1);
200
+ buf += code.slice(i, end < 0 ? code.length : end + 1);
201
+ i = end < 0 ? code.length : end;
202
+ continue;
203
+ }
204
+ if (ch === '{') {
205
+ const header = buf.slice(buf.lastIndexOf(';') + 1).trim();
206
+ flushDecls(buf.slice(0, buf.lastIndexOf(';') + 1));
207
+ stack.push({ header, props: new Set() });
208
+ buf = '';
209
+ continue;
210
+ }
211
+ if (ch === '}') {
212
+ flushDecls(buf);
213
+ buf = '';
214
+ const block = stack.pop();
215
+ if (!block || block.props.size === 0) continue;
216
+ const chain = [...stack.map((b) => b.header), block.header];
217
+ if (!chain.some((h) => /^@layer\s+utilities\b/.test(h))) continue;
218
+ // The innermost SELECTOR carrying these declarations (a nested `@media` block's parent).
219
+ const selector = [...chain].reverse().find((h) => !h.startsWith('@'));
220
+ const m = selector ? /^\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[A-Za-z0-9_-])+)/.exec(selector) : null;
221
+ if (!m) continue;
222
+ const cls = unescapeIdent(m[1] as string);
223
+ // A VARIANT is a class with a variant prefix (`sm:`, `hover:`, `dark:`, `@md:`). Not "inside
224
+ // a @media": `.container` emits its own breakpoint blocks and is still a base utility, and
225
+ // `.divide-y > :not(:last-child)` is a base with a structural selector.
226
+ out.push({ cls, variant: hasVariantPrefix(cls), properties: block.props });
227
+ continue;
228
+ }
229
+ buf += ch;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * 🔴 A BASE utility that comes after a VARIANT setting the same property, in `@layer utilities`.
236
+ *
237
+ * One Tailwind pass can never produce this: it emits every base utility, then every variant, so a
238
+ * variant always follows the base it overrides. It appears only when two separately SORTED passes
239
+ * are concatenated — cursedbelt's precompiled areas (≤ 5.0.x) after an app's own pass — and then
240
+ * the base wins at every width: flix's `.hidden` (from core.css, later) beat its own `.sm:flex`, and
241
+ * the desktop nav vanished at 1440 px (task 2124). Same specificity, later wins.
242
+ *
243
+ * Conservative on purpose — EVERY such pair, not only pairs the markup combines. Since 5.1.0 an
244
+ * app's build has one pass, so a healthy build has zero pairs and this costs no false positive; a
245
+ * build that has any is a build with two passes, and the pair the markup does not combine TODAY
246
+ * is the one the next commit does (G2 measured latent `sm:p-4`/`p-1.5` in collections). Judging only
247
+ * combined pairs would pass that build and ship the bug one className later. `@supports` wrappers
248
+ * are a base utility's own fallback, not a variant.
249
+ */
250
+ export function cascadeFaults(cssTexts: readonly string[]): CascadeFault[] {
251
+ const faults: CascadeFault[] = [];
252
+ const seen = new Set<string>();
253
+ for (const css of cssTexts) {
254
+ const rules = utilityRules(css);
255
+ const variantsSoFar: UtilityRule[] = [];
256
+ for (const rule of rules) {
257
+ if (rule.variant) {
258
+ variantsSoFar.push(rule);
259
+ continue;
260
+ }
261
+ for (const v of variantsSoFar) {
262
+ const shared = [...rule.properties].filter((p) => v.properties.has(p));
263
+ if (shared.length === 0) continue;
264
+ const key = `${rule.cls}|${v.cls}`;
265
+ if (seen.has(key)) continue;
266
+ seen.add(key);
267
+ faults.push({ base: rule.cls, variant: v.cls, properties: shared });
268
+ }
269
+ }
270
+ }
271
+ return faults;
272
+ }
273
+
70
274
  function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
71
275
  for (const entry of readdirSync(dir)) {
72
276
  if (entry === 'node_modules') continue;
@@ -84,24 +288,28 @@ export interface AreaCheckResult {
84
288
  cssFiles: number;
85
289
  /** How many belt classes the JS references — a zero here means the check saw nothing. */
86
290
  referenced: number;
291
+ /** {@link cascadeFaults} over the built CSS — base utilities that override a variant. */
292
+ outOfOrder: CascadeFault[];
87
293
  }
88
294
 
89
295
  /** The measurement, over explicit texts — what the spec drives. */
90
296
  export function checkAreaStyles(
91
297
  input: { js: string[]; css: string[] },
92
298
  belt: { union: string; areas: Record<string, string> },
93
- ): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles'> {
299
+ ): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles' | 'outOfOrder'> {
94
300
  const union = classesInCss(belt.union);
95
301
  const areaOf = new Map<string, string>();
96
302
  for (const [area, css] of Object.entries(belt.areas)) {
97
- for (const name of classesInCss(css)) if (!areaOf.has(name)) areaOf.set(name, area);
303
+ for (const name of [...classesInCss(css), ...inlineCandidatesIn(css)]) if (!areaOf.has(name)) areaOf.set(name, area);
98
304
  }
99
305
  const emitted = new Set<string>();
100
306
  for (const css of input.css) for (const name of classesInCss(css)) emitted.add(name);
101
307
  const referenced = new Set<string>();
102
308
  for (const js of input.js) for (const token of tokensInJs(js)) if (union.has(token) && areaOf.has(token)) referenced.add(token);
309
+ const allJs = input.js.join('\n');
103
310
  const missing = [...referenced]
104
311
  .filter((name) => !emitted.has(name))
312
+ .filter((name) => !onlyInMergeTables(name, allJs) && !onlyAsPropertyName(name, allJs))
105
313
  .sort()
106
314
  .map((className) => ({ className, area: areaOf.get(className) as string }));
107
315
  return { missing, referenced: referenced.size };
@@ -123,7 +331,8 @@ export function checkDist(distDir: string): AreaCheckResult {
123
331
  { js: jsFiles.map((f) => readFileSync(f, 'utf8')), css: cssFiles.map((f) => readFileSync(f, 'utf8')) },
124
332
  readBeltSheets(),
125
333
  );
126
- return { ...result, jsFiles: jsFiles.length, cssFiles: cssFiles.length };
334
+ const cssTexts = cssFiles.map((f) => readFileSync(f, 'utf8'));
335
+ return { ...result, outOfOrder: cascadeFaults(cssTexts), jsFiles: jsFiles.length, cssFiles: cssFiles.length };
127
336
  }
128
337
 
129
338
  if (import.meta.main) {
@@ -137,14 +346,25 @@ if (import.meta.main) {
137
346
  console.error(`✗ ${dir} holds ${result.jsFiles} .js and ${result.cssFiles} .css file(s) — nothing to compare.`);
138
347
  process.exit(2);
139
348
  }
349
+ if (result.outOfOrder.length > 0) {
350
+ console.error(`✗ ${result.outOfOrder.length} base utilit(y|ies) come AFTER a variant that sets the same property, so the base wins at every width:`);
351
+ for (const f of result.outOfOrder.slice(0, 12)) console.error(` .${f.base} (later) overrides .${f.variant} — ${f.properties.join(', ')}`);
352
+ if (result.outOfOrder.length > 12) console.error(` … +${result.outOfOrder.length - 12} more`);
353
+ console.error(' Two sorted Tailwind passes were concatenated — cursedbelt ≤ 5.0.x precompiled areas after the app\'s own.');
354
+ console.error(' cursedbelt ≥ 5.1.0 areas are candidates for the app\'s ONE pass: upgrade, and rebuild.');
355
+ }
140
356
  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).`);
357
+ if (result.outOfOrder.length > 0) process.exit(1);
358
+ console.log(
359
+ `✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled, ` +
360
+ `and no base utility follows a variant (${result.jsFiles} js, ${result.cssFiles} css).`,
361
+ );
142
362
  process.exit(0);
143
363
  }
144
364
  const byArea = new Map<string, string[]>();
145
365
  for (const { className, area } of result.missing) byArea.set(area, [...(byArea.get(area) ?? []), className]);
146
366
  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";`:');
367
+ console.error(' Import the area that carries them — `@import "cursedbelt/styles-areas/<area>.css";`:');
148
368
  for (const [area, names] of [...byArea].sort()) console.error(` ${area}.css ← ${names.slice(0, 8).join(' ')}${names.length > 8 ? ` … +${names.length - 8}` : ''}`);
149
369
  process.exit(1);
150
370
  }
@@ -30,8 +30,15 @@ export const flatten = (css: string): Map<string, Set<string>> => {
30
30
  const stack: string[] = [];
31
31
  const open: string[][] = [[]];
32
32
  let buf = '';
33
+ // 🔴 One spelling per declaration: whitespace collapsed, and a quoted string normalised to
34
+ // double quotes. Since 5.1.0 an area is compiled by the APP's Tailwind pass, which prints
35
+ // `"Segoe UI"` where the precompiled union printed `'Segoe UI'` over a line break — the same
36
+ // declaration, and a comparison that called it a lost one was comparing formatters.
33
37
  const push = (raw: string): void => {
34
- const decl = raw.trim();
38
+ const decl = raw
39
+ .trim()
40
+ .replace(/\s+/g, ' ')
41
+ .replace(/'([^'"]*)'/g, '"$1"');
35
42
  if (decl) open[open.length - 1]?.push(decl);
36
43
  };
37
44
  for (let i = 0; i < css.length; i++) {
package/scripts/gate.ts CHANGED
@@ -65,7 +65,7 @@ function viaWorktree(from: string): string | null {
65
65
  });
66
66
  if (git.status !== 0) return null;
67
67
  const gitDir = (git.stdout ?? "").trim();
68
- if (!gitDir || !gitDir.startsWith("/")) return null;
68
+ if (!gitDir?.startsWith("/")) return null;
69
69
  // `<primary>/.git` → `<primary>`, then the ordinary walk up.
70
70
  return findForgeRoot(dirname(gitDir));
71
71
  } catch {