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.
- package/dist/react/engagement/beacon.d.ts +38 -0
- package/dist/react/engagement/beacon.d.ts.map +1 -0
- package/dist/react/engagement/beacon.js +62 -0
- package/dist/react/engagement/beacon.js.map +1 -0
- package/dist/react/engagement/beaconClient.d.ts +119 -0
- package/dist/react/engagement/beaconClient.d.ts.map +1 -0
- package/dist/react/engagement/beaconClient.js +203 -0
- package/dist/react/engagement/beaconClient.js.map +1 -0
- package/dist/react/engagement/index.d.ts +3 -0
- package/dist/react/engagement/index.d.ts.map +1 -0
- package/dist/react/engagement/index.js +5 -0
- package/dist/react/engagement/index.js.map +1 -0
- package/dist/react/lib/directUpload.d.ts +130 -0
- package/dist/react/lib/directUpload.d.ts.map +1 -0
- package/dist/react/lib/directUpload.js +228 -0
- package/dist/react/lib/directUpload.js.map +1 -0
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/MediaThumb.d.ts.map +1 -1
- package/dist/react/media/MediaThumb.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/styles-areas/activity-bar.css +8 -11
- package/dist/styles-areas/analytics.css +30 -653
- package/dist/styles-areas/auth.css +10 -135
- package/dist/styles-areas/calendar.css +22 -397
- package/dist/styles-areas/chart-table.css +8 -20
- package/dist/styles-areas/charts.css +9 -70
- package/dist/styles-areas/chat.css +17 -345
- package/dist/styles-areas/clipboard.css +7 -7
- package/dist/styles-areas/code-editor.css +8 -111
- package/dist/styles-areas/companion-link.css +8 -51
- package/dist/styles-areas/core.css +131 -2488
- package/dist/styles-areas/dashboard-grid.css +10 -65
- package/dist/styles-areas/data-table.css +30 -638
- package/dist/styles-areas/deep-link.css +7 -7
- package/dist/styles-areas/diff-viewer.css +8 -11
- package/dist/styles-areas/disk-usage.css +13 -243
- package/dist/styles-areas/emoji.css +9 -135
- package/dist/styles-areas/fields.css +32 -563
- package/dist/styles-areas/file-tree.css +8 -14
- package/dist/styles-areas/filter-rail.css +14 -216
- package/dist/styles-areas/folder-tree.css +12 -200
- package/dist/styles-areas/keep-awake.css +7 -7
- package/dist/styles-areas/layout-engine.css +12 -220
- package/dist/styles-areas/markdown.css +9 -68
- package/dist/styles-areas/master-detail.css +14 -220
- package/dist/styles-areas/master-lock.css +9 -113
- package/dist/styles-areas/media-gallery.css +15 -332
- package/dist/styles-areas/media.css +20 -623
- package/dist/styles-areas/nav.css +24 -469
- package/dist/styles-areas/notifications.css +12 -226
- package/dist/styles-areas/overlays.css +14 -114
- package/dist/styles-areas/palette.css +15 -121
- package/dist/styles-areas/pdf-viewer.css +8 -28
- package/dist/styles-areas/problem-detail.css +9 -78
- package/dist/styles-areas/rich-text.css +9 -148
- package/dist/styles-areas/sharing.css +13 -134
- package/dist/styles-areas/spreadsheet.css +8 -18
- package/dist/styles-areas/stats.css +19 -262
- package/dist/styles-areas/test-report.css +13 -114
- package/dist/styles-areas/virtual.css +8 -11
- package/dist/styles-areas/wizard.css +21 -362
- package/dist/styles-areas/workbench.css +27 -524
- package/dist/styles-areas/workbook-viewer.css +8 -25
- package/dist/styles-static.css +1 -0
- package/dist/styles.css +1 -0
- package/package.json +20 -2
- package/scripts/checkAreaStyles.spec.ts +158 -1
- package/scripts/checkAreaStyles.ts +226 -6
- package/scripts/cssRules.ts +8 -1
- package/scripts/gate.ts +1 -1
- package/scripts/generateAreaStyles.ts +130 -10
- package/scripts/paths.ts +1 -1
- package/scripts/styleAreas.ts +2 -0
- package/src/react/components/ActionBar.spec.tsx +1 -0
- package/src/react/engagement/beacon.tsx +63 -0
- package/src/react/engagement/beaconClient.spec.ts +138 -0
- package/src/react/engagement/beaconClient.ts +228 -0
- package/src/react/engagement/index.ts +13 -0
- package/src/react/file-tree/FileTree.tsx +3 -3
- package/src/react/lib/directUpload.spec.ts +128 -0
- package/src/react/lib/directUpload.ts +334 -0
- package/src/react/media/FocusViewer.tsx +1 -0
- package/src/react/media/MediaThumb.tsx +0 -1
- package/src/react/media/VideoTurner.tsx +0 -1
- package/src/react/media-gallery/GalleryTable.tsx +0 -1
- package/src/react/media-gallery/MediaGallery.tsx +0 -1
- package/src/styles-areas/activity-bar.css +8 -11
- package/src/styles-areas/analytics.css +30 -653
- package/src/styles-areas/auth.css +10 -135
- package/src/styles-areas/calendar.css +22 -397
- package/src/styles-areas/chart-table.css +8 -20
- package/src/styles-areas/charts.css +9 -70
- package/src/styles-areas/chat.css +17 -345
- package/src/styles-areas/clipboard.css +7 -7
- package/src/styles-areas/code-editor.css +8 -111
- package/src/styles-areas/companion-link.css +8 -51
- package/src/styles-areas/core.css +131 -2488
- package/src/styles-areas/dashboard-grid.css +10 -65
- package/src/styles-areas/data-table.css +30 -638
- package/src/styles-areas/deep-link.css +7 -7
- package/src/styles-areas/diff-viewer.css +8 -11
- package/src/styles-areas/disk-usage.css +13 -243
- package/src/styles-areas/emoji.css +9 -135
- package/src/styles-areas/fields.css +32 -563
- package/src/styles-areas/file-tree.css +8 -14
- package/src/styles-areas/filter-rail.css +14 -216
- package/src/styles-areas/folder-tree.css +12 -200
- package/src/styles-areas/keep-awake.css +7 -7
- package/src/styles-areas/layout-engine.css +12 -220
- package/src/styles-areas/markdown.css +9 -68
- package/src/styles-areas/master-detail.css +14 -220
- package/src/styles-areas/master-lock.css +9 -113
- package/src/styles-areas/media-gallery.css +15 -332
- package/src/styles-areas/media.css +20 -623
- package/src/styles-areas/nav.css +24 -469
- package/src/styles-areas/notifications.css +12 -226
- package/src/styles-areas/overlays.css +14 -114
- package/src/styles-areas/palette.css +15 -121
- package/src/styles-areas/pdf-viewer.css +8 -28
- package/src/styles-areas/problem-detail.css +9 -78
- package/src/styles-areas/rich-text.css +9 -148
- package/src/styles-areas/sharing.css +13 -134
- package/src/styles-areas/spreadsheet.css +8 -18
- package/src/styles-areas/stats.css +19 -262
- package/src/styles-areas/test-report.css +13 -114
- package/src/styles-areas/virtual.css +8 -11
- package/src/styles-areas/wizard.css +21 -362
- package/src/styles-areas/workbench.css +27 -524
- package/src/styles-areas/workbook-viewer.css +8 -25
- package/src/styles-static.css +1 -0
- package/src/styles.css +1 -0
- 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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/scripts/cssRules.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|