cursedbelt 5.1.0 → 5.3.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/styles-areas/auth.css +2 -2
- package/dist/styles-areas/core.css +14 -2
- package/dist/styles-areas/spreadsheet.css +2 -2
- package/dist/styles-areas/workbook-viewer.css +2 -2
- package/dist/styles-utilities.css +12 -0
- package/package.json +13 -1
- package/scripts/checkAreaStyles.spec.ts +90 -0
- package/scripts/checkAreaStyles.ts +127 -6
- package/scripts/generateAreaStyles.ts +12 -4
- package/scripts/generateUtilityStyles.ts +76 -1
- package/scripts/styleAreas.ts +2 -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/lib/directUpload.spec.ts +128 -0
- package/src/react/lib/directUpload.ts +334 -0
- package/src/styles-areas/auth.css +2 -2
- package/src/styles-areas/core.css +14 -2
- package/src/styles-areas/spreadsheet.css +2 -2
- package/src/styles-areas/workbook-viewer.css +2 -2
- package/src/styles-utilities.css +12 -0
|
@@ -29,12 +29,20 @@
|
|
|
29
29
|
* {@link cascadeFaults}: a base utility after a variant that sets the same property is two sorted
|
|
30
30
|
* Tailwind passes concatenated, and the base wins at every width. Both halves exit 1.
|
|
31
31
|
*
|
|
32
|
+
* ── …and whether every THEME variable the build reads is defined (5.3.0) ────────────────
|
|
33
|
+
* {@link undefinedThemeVariables}: a `var(--color-card)` in the built JS or CSS whose `--color-card`
|
|
34
|
+
* no built stylesheet declares. Tailwind prunes theme variables nothing in its pass names, and a
|
|
35
|
+
* component that reads one by name from a style prop is invisible to the class check above — the
|
|
36
|
+
* hole station's frozen DataTable column fell through (F4, 2026-09-23). Exit 1 as well.
|
|
37
|
+
*
|
|
32
38
|
* Usage, from an app, after its build:
|
|
33
39
|
* bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist
|
|
34
|
-
* Exit 0 = complete, 1 = a class is unstyled (listed with its area),
|
|
40
|
+
* Exit 0 = complete, 1 = a class is unstyled (listed with its area), a base utility follows a
|
|
41
|
+
* variant, or a theme variable read is declared nowhere; 2 = could not measure
|
|
35
42
|
* (no JS or no CSS under the directory — a check that measured nothing has not passed).
|
|
36
43
|
*/
|
|
37
44
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
45
|
+
import { createRequire } from 'node:module';
|
|
38
46
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
39
47
|
import { fileURLToPath } from 'node:url';
|
|
40
48
|
|
|
@@ -132,6 +140,25 @@ export function onlyInMergeTables(token: string, js: string): boolean {
|
|
|
132
140
|
return seen > 0 && [...js.matchAll(bare)].length === seen;
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Does `token` occur in the built JS only as a PROPERTY NAME — the left side of an `in` test
|
|
145
|
+
* (`"invert"in e`, d3's scale check inside recharts) or an object key (`{"invert":…}`)? A className
|
|
146
|
+
* is always a string VALUE, never either (task 2131, measured on station's build 2026-09-23: the
|
|
147
|
+
* only false reference left after the tailwind-merge filter, and it demanded the `media` area).
|
|
148
|
+
* Refused as soon as the token also appears in any class string.
|
|
149
|
+
*/
|
|
150
|
+
export function onlyAsPropertyName(token: string, js: string): boolean {
|
|
151
|
+
const quoted = new RegExp(`(["'])${escapeRe(token)}\\1`, 'g');
|
|
152
|
+
let seen = 0;
|
|
153
|
+
for (const m of js.matchAll(quoted)) {
|
|
154
|
+
seen++;
|
|
155
|
+
const after = js.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 4);
|
|
156
|
+
if (!/^\s*(?::|in\b)/.test(after)) return false;
|
|
157
|
+
}
|
|
158
|
+
const bare = new RegExp(`(^|[\\s"'\`])${escapeRe(token)}(?=[\\s"'\`]|$)`, 'g');
|
|
159
|
+
return seen > 0 && [...js.matchAll(bare)].length === seen;
|
|
160
|
+
}
|
|
161
|
+
|
|
135
162
|
// ── the ORDER check (5.1.0, task 2124) ─────────────────────────────────────────────────
|
|
136
163
|
|
|
137
164
|
export interface CascadeFault {
|
|
@@ -252,6 +279,81 @@ export function cascadeFaults(cssTexts: readonly string[]): CascadeFault[] {
|
|
|
252
279
|
return faults;
|
|
253
280
|
}
|
|
254
281
|
|
|
282
|
+
// ── the THEME VARIABLE check (5.3.0) ───────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Every custom property an `@theme` block declares (any `@theme inline`/`static`/… form). These — and
|
|
286
|
+
* only these — are what Tailwind PRUNES: it emits a theme variable into `:root` only when its pass
|
|
287
|
+
* sees it used. Every other custom property is written down by a stylesheet unconditionally or set
|
|
288
|
+
* at runtime (`--radix-*`, sonner's `--offset`, a component's `--cb-frozen-bg`), and an area split
|
|
289
|
+
* cannot lose it — which is why the check is scoped here and needs no allowlist.
|
|
290
|
+
*/
|
|
291
|
+
export function themeVariablesIn(css: string): Set<string> {
|
|
292
|
+
const out = new Set<string>();
|
|
293
|
+
const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
294
|
+
for (const m of code.matchAll(/@theme\b[^{]*\{/g)) {
|
|
295
|
+
let depth = 1;
|
|
296
|
+
let i = (m.index ?? 0) + m[0].length;
|
|
297
|
+
const start = i;
|
|
298
|
+
for (; i < code.length && depth > 0; i++) {
|
|
299
|
+
if (code[i] === '{') depth++;
|
|
300
|
+
else if (code[i] === '}') depth--;
|
|
301
|
+
}
|
|
302
|
+
for (const d of code.slice(start, i).matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:\s*([^;}]*)/g)) {
|
|
303
|
+
// `initial` is Tailwind's "unset" — `--default-font-feature-settings: --theme(--font-sans--…, initial)`
|
|
304
|
+
// is declared only when the app sets the key it names, and preflight reads it with a fallback.
|
|
305
|
+
// Measured on station's union build: the four `--default-*-settings` were the only noise.
|
|
306
|
+
if (/^(?:initial|--theme\([^)]*,\s*initial\))$/.test((d[2] as string).trim())) continue;
|
|
307
|
+
out.add(d[1] as string);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Every custom property a stylesheet DECLARES: `--x:` in any block, or `@property --x`. */
|
|
314
|
+
export function variablesDeclared(css: string): Set<string> {
|
|
315
|
+
const out = new Set<string>();
|
|
316
|
+
const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
317
|
+
for (const m of code.matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:/g)) out.add(m[1] as string);
|
|
318
|
+
for (const m of code.matchAll(/@property\s+(--[\w-]+)/g)) out.add(m[1] as string);
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Every `var(--x…)` a text reads — a JS bundle's style strings or a stylesheet's values. */
|
|
323
|
+
export function variablesReferenced(text: string): Set<string> {
|
|
324
|
+
return new Set([...text.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] as string));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 🔴 The THEME variables the built JS or CSS reads with `var()` that no built stylesheet declares.
|
|
329
|
+
* Each one is a value that silently resolves to nothing — `var(--color-card)` as a background is
|
|
330
|
+
* transparent, not an error. A fallback (`var(--x, red)`) does not excuse one: it renders, but not
|
|
331
|
+
* as the theme says, which is the same divergence from the union.
|
|
332
|
+
*/
|
|
333
|
+
export function undefinedThemeVariables(input: { js: string[]; css: string[] }, theme: ReadonlySet<string>): string[] {
|
|
334
|
+
const declared = new Set<string>();
|
|
335
|
+
for (const css of input.css) for (const name of variablesDeclared(css)) declared.add(name);
|
|
336
|
+
const read = new Set<string>();
|
|
337
|
+
for (const text of [...input.js, ...input.css]) for (const name of variablesReferenced(text)) read.add(name);
|
|
338
|
+
return [...read].filter((name) => theme.has(name) && !declared.has(name)).sort();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The theme a cursedbelt consumer compiles against: this package's `theme.css` and
|
|
343
|
+
* `styles-static.css`, and Tailwind's own `theme.css` (resolved from this package, which in an app
|
|
344
|
+
* is the app's copy). 🔴 Throws rather than shrinking the set when Tailwind cannot be found — a
|
|
345
|
+
* check over half the theme would pass on exactly the variables it cannot see.
|
|
346
|
+
*/
|
|
347
|
+
export function readThemeVariables(src: string | undefined = SRC): Set<string> {
|
|
348
|
+
if (!src) throw new Error("cursedbelt's src/ is not beside this script — the package is incomplete");
|
|
349
|
+
const tailwindTheme = createRequire(join(src, '..', 'package.json')).resolve('tailwindcss/theme.css');
|
|
350
|
+
const out = new Set<string>();
|
|
351
|
+
for (const file of [join(src, 'theme.css'), join(src, 'styles-static.css'), tailwindTheme]) {
|
|
352
|
+
for (const name of themeVariablesIn(readFileSync(file, 'utf8'))) out.add(name);
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
255
357
|
function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
|
|
256
358
|
for (const entry of readdirSync(dir)) {
|
|
257
359
|
if (entry === 'node_modules') continue;
|
|
@@ -271,13 +373,15 @@ export interface AreaCheckResult {
|
|
|
271
373
|
referenced: number;
|
|
272
374
|
/** {@link cascadeFaults} over the built CSS — base utilities that override a variant. */
|
|
273
375
|
outOfOrder: CascadeFault[];
|
|
376
|
+
/** {@link undefinedThemeVariables} — theme variables read by `var()` and declared by no built CSS. */
|
|
377
|
+
undefinedVariables: string[];
|
|
274
378
|
}
|
|
275
379
|
|
|
276
380
|
/** The measurement, over explicit texts — what the spec drives. */
|
|
277
381
|
export function checkAreaStyles(
|
|
278
382
|
input: { js: string[]; css: string[] },
|
|
279
383
|
belt: { union: string; areas: Record<string, string> },
|
|
280
|
-
): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles' | 'outOfOrder'> {
|
|
384
|
+
): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles' | 'outOfOrder' | 'undefinedVariables'> {
|
|
281
385
|
const union = classesInCss(belt.union);
|
|
282
386
|
const areaOf = new Map<string, string>();
|
|
283
387
|
for (const [area, css] of Object.entries(belt.areas)) {
|
|
@@ -290,7 +394,7 @@ export function checkAreaStyles(
|
|
|
290
394
|
const allJs = input.js.join('\n');
|
|
291
395
|
const missing = [...referenced]
|
|
292
396
|
.filter((name) => !emitted.has(name))
|
|
293
|
-
.filter((name) => !onlyInMergeTables(name, allJs))
|
|
397
|
+
.filter((name) => !onlyInMergeTables(name, allJs) && !onlyAsPropertyName(name, allJs))
|
|
294
398
|
.sort()
|
|
295
399
|
.map((className) => ({ className, area: areaOf.get(className) as string }));
|
|
296
400
|
return { missing, referenced: referenced.size };
|
|
@@ -313,7 +417,14 @@ export function checkDist(distDir: string): AreaCheckResult {
|
|
|
313
417
|
readBeltSheets(),
|
|
314
418
|
);
|
|
315
419
|
const cssTexts = cssFiles.map((f) => readFileSync(f, 'utf8'));
|
|
316
|
-
|
|
420
|
+
const jsTexts = jsFiles.map((f) => readFileSync(f, 'utf8'));
|
|
421
|
+
return {
|
|
422
|
+
...result,
|
|
423
|
+
outOfOrder: cascadeFaults(cssTexts),
|
|
424
|
+
undefinedVariables: undefinedThemeVariables({ js: jsTexts, css: cssTexts }, readThemeVariables()),
|
|
425
|
+
jsFiles: jsFiles.length,
|
|
426
|
+
cssFiles: cssFiles.length,
|
|
427
|
+
};
|
|
317
428
|
}
|
|
318
429
|
|
|
319
430
|
if (import.meta.main) {
|
|
@@ -334,11 +445,21 @@ if (import.meta.main) {
|
|
|
334
445
|
console.error(' Two sorted Tailwind passes were concatenated — cursedbelt ≤ 5.0.x precompiled areas after the app\'s own.');
|
|
335
446
|
console.error(' cursedbelt ≥ 5.1.0 areas are candidates for the app\'s ONE pass: upgrade, and rebuild.');
|
|
336
447
|
}
|
|
448
|
+
if (result.undefinedVariables.length > 0) {
|
|
449
|
+
console.error(
|
|
450
|
+
`✗ ${result.undefinedVariables.length} theme variable(s) the build reads with var() are declared by NO built stylesheet — ` +
|
|
451
|
+
'each resolves to nothing (a transparent background, an unset size):',
|
|
452
|
+
);
|
|
453
|
+
console.error(` ${result.undefinedVariables.join(' ')}`);
|
|
454
|
+
console.error(' Tailwind emits a theme variable only when its pass sees it used. cursedbelt ≥ 5.3.0 names every one its');
|
|
455
|
+
console.error(' components read in styles-areas/core.css — import core, on ≥ 5.3.0. A variable of the app\'s own: name it');
|
|
456
|
+
console.error(' in a file the app\'s Tailwind pass scans.');
|
|
457
|
+
}
|
|
337
458
|
if (result.missing.length === 0) {
|
|
338
|
-
if (result.outOfOrder.length > 0) process.exit(1);
|
|
459
|
+
if (result.outOfOrder.length > 0 || result.undefinedVariables.length > 0) process.exit(1);
|
|
339
460
|
console.log(
|
|
340
461
|
`✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled, ` +
|
|
341
|
-
`
|
|
462
|
+
`no base utility follows a variant, and every theme variable read is declared (${result.jsFiles} js, ${result.cssFiles} css).`,
|
|
342
463
|
);
|
|
343
464
|
process.exit(0);
|
|
344
465
|
}
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
*/
|
|
85
85
|
import { REPO_ROOT, STATIC_STYLESHEET, readStylesheets } from './generateStaticStyles';
|
|
86
86
|
import { classesInCss } from './checkAreaStyles';
|
|
87
|
-
import { UTILITY_INPUT, compileStylesheet } from './generateUtilityStyles';
|
|
87
|
+
import { UTILITY_INPUT, compileStylesheet, themeReadBlock, themeVariablesReadByName } from './generateUtilityStyles';
|
|
88
88
|
import {
|
|
89
89
|
AREA_DIR,
|
|
90
90
|
AREAS,
|
|
@@ -195,7 +195,7 @@ export const inlineCandidates = async (candidates: readonly string[], area: stri
|
|
|
195
195
|
// Tailwind escapes a selector the CSS.escape way; a candidate whose escaped form is in the
|
|
196
196
|
// output is kept even when unescaping would not round-trip it (a `…` from a doc comment did not).
|
|
197
197
|
// A `--token` candidate is a THEME VARIABLE the modules read (\`var(--text-sm)\` in a style
|
|
198
|
-
// prop)
|
|
198
|
+
// prop). It is kept for the proof below, then dropped from what is written — see the return.
|
|
199
199
|
const kept = writable.filter(
|
|
200
200
|
(c) => emitted.has(c) || full.includes(`.${cssEscape(c)}`) || (/^--[\w-]+$/.test(c) && full.includes(`${c}:`)),
|
|
201
201
|
);
|
|
@@ -207,7 +207,11 @@ export const inlineCandidates = async (candidates: readonly string[], area: stri
|
|
|
207
207
|
'widen the filter rather than lose it.',
|
|
208
208
|
);
|
|
209
209
|
}
|
|
210
|
-
|
|
210
|
+
// …but a `--token` is not WRITTEN: `@source inline` cannot mark a theme variable (tailwindcss
|
|
211
|
+
// 4.3.3 adds inline candidates without the `--` branch scanned ones take — measured), so one
|
|
212
|
+
// here would claim a variable the app never gets. `core` carries every one of them instead,
|
|
213
|
+
// as a `var()` the app's pass does see (`themeReadBlock`, 5.3.0).
|
|
214
|
+
return kept.filter((c) => !/^--[\w-]+$/.test(c));
|
|
211
215
|
};
|
|
212
216
|
|
|
213
217
|
/** `CSS.escape` for a class name — what Tailwind writes into a selector. */
|
|
@@ -291,6 +295,10 @@ export const buildAreaStyles = async (): Promise<BuiltArea[]> => {
|
|
|
291
295
|
raw.set(area.name, await candidatesIn(closures.get(area.name) as Set<string>));
|
|
292
296
|
}
|
|
293
297
|
|
|
298
|
+
// 🔴 The theme variables components read BY NAME go in core, whole: an area is candidates, and
|
|
299
|
+
// no candidate can make the app's pass emit one (`themeVariablesReadByName` has the measurement).
|
|
300
|
+
const themeRead = themeReadBlock(await themeVariablesReadByName());
|
|
301
|
+
|
|
294
302
|
const built: BuiltArea[] = [];
|
|
295
303
|
for (const area of AREAS) {
|
|
296
304
|
const closure = closures.get(area.name) as Set<string>;
|
|
@@ -311,7 +319,7 @@ export const buildAreaStyles = async (): Promise<BuiltArea[]> => {
|
|
|
311
319
|
}
|
|
312
320
|
built.push({
|
|
313
321
|
name: area.name,
|
|
314
|
-
css: `${banner(area, inline.length, area.name !== CORE)}${inlineSource(inline)}`,
|
|
322
|
+
css: `${banner(area, inline.length, area.name !== CORE)}${inlineSource(inline)}${area.name === CORE ? themeRead : ''}`,
|
|
315
323
|
candidates: inline,
|
|
316
324
|
modules: [...closure].map((f) => f.replace(`${REPO_ROOT}/`, '')).sort(),
|
|
317
325
|
});
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
import { compile } from '@tailwindcss/node';
|
|
59
59
|
import { Scanner } from '@tailwindcss/oxide';
|
|
60
60
|
import { REPO_ROOT, STATIC_STYLESHEET, readStylesheets } from './generateStaticStyles';
|
|
61
|
+
import { allModules, candidatesIn, nonModuleFiles } from './styleAreas';
|
|
61
62
|
|
|
62
63
|
export { REPO_ROOT };
|
|
63
64
|
|
|
@@ -124,6 +125,78 @@ export const compileStylesheet = (
|
|
|
124
125
|
input: string,
|
|
125
126
|
): ReturnType<typeof compile> => compile(input, { base: STYLES_BASE, onDependency: noDependency });
|
|
126
127
|
|
|
128
|
+
// ── Theme variables the components read BY NAME (5.3.0) ─────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The custom property whose value names every theme variable {@link themeVariablesReadByName}
|
|
132
|
+
* returns. Nothing reads it; it exists so the app's Tailwind pass sees a `var()` of each.
|
|
133
|
+
*/
|
|
134
|
+
export const THEME_READ_PROPERTY = '--cb-theme-read-by-name';
|
|
135
|
+
|
|
136
|
+
/** Every theme a consumer imports, and nothing scanned — what decides "Tailwind would prune this". */
|
|
137
|
+
const THEME_PROBE_INPUT = '@import "tailwindcss" source(none);\n@import "./styles-static.css";\n@import "./theme.css";\n';
|
|
138
|
+
|
|
139
|
+
/** A collapse tripwire, not a ratchet: 9 on the day it was set (tailwindcss 4.3.3). */
|
|
140
|
+
export const MIN_THEME_READ_BY_NAME = 4;
|
|
141
|
+
|
|
142
|
+
const declaresVariable = (css: string, name: string): boolean =>
|
|
143
|
+
new RegExp(`(^|[\\s;{])${name.replace(/[-]/g, '\\-')}\\s*:`).test(css);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 🔴 The THEME variables cursedbelt's components read by NAME, which Tailwind emits only when
|
|
147
|
+
* something in the app's own pass names them.
|
|
148
|
+
*
|
|
149
|
+
* `DataTable` sets `--cb-frozen-bg` inline to `var(--color-card)`, `DropIndicator` paints with
|
|
150
|
+
* `var(--color-accent)`, `focusViewer.css` sizes a caption with `var(--text-caption)`. Tailwind v4
|
|
151
|
+
* emits a theme variable into `:root` only when it is USED — by a utility, by a `var()` in CSS it
|
|
152
|
+
* compiles, or by a `--name` token its scanner finds in a source file. The union route scans this
|
|
153
|
+
* package's tree, so it sees the names; an area is class candidates, so an app on the areas had
|
|
154
|
+
* NO `--color-card` and station's frozen DataTable column went transparent (F4, 2026-09-23,
|
|
155
|
+
* `reports/2026-09-23/2026-09-23-f4-station-desk-flix-final-adoption.md`).
|
|
156
|
+
*
|
|
157
|
+
* `@source inline("--color-card")` does NOT fix it — measured on tailwindcss 4.3.3: inline
|
|
158
|
+
* candidates skip the `--` branch the scanner's candidates take, so they mark nothing. A `var()`
|
|
159
|
+
* in CSS the app's pass compiles does, so {@link themeReadBlock} writes one.
|
|
160
|
+
*
|
|
161
|
+
* DERIVED, never listed: every `--token` the scanner finds in a module or hand-written `.css` under
|
|
162
|
+
* `src/react` that a theme-only compile emits when named and omits when not. A new
|
|
163
|
+
* `var(--color-ring)` in a component joins the list at the next `bun run build`;
|
|
164
|
+
* `scripts/checkAreaStyles.ts` is what fails an app build when one is still missing anyway.
|
|
165
|
+
*/
|
|
166
|
+
export const themeVariablesReadByName = async (): Promise<string[]> => {
|
|
167
|
+
const files = [...allModules(), ...nonModuleFiles().filter((f) => f.endsWith('.css'))];
|
|
168
|
+
const named = [...(await candidatesIn(files))].filter((c) => /^--[\w-]+$/.test(c)).sort();
|
|
169
|
+
const bare = (await compileStylesheet(THEME_PROBE_INPUT)).build([]);
|
|
170
|
+
const marked = (await compileStylesheet(THEME_PROBE_INPUT)).build(named);
|
|
171
|
+
const pruned = named.filter((v) => declaresVariable(marked, v) && !declaresVariable(bare, v));
|
|
172
|
+
if (pruned.length < MIN_THEME_READ_BY_NAME) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`only ${pruned.length} theme variable(s) read by name were found (floor ${MIN_THEME_READ_BY_NAME}) — ` +
|
|
175
|
+
`a collapsed scan of ${files.length} file(s), not a design system that stopped reading them.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return pruned;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The declaration that makes an app's Tailwind pass emit `vars`: one custom property on `:root`
|
|
183
|
+
* whose value `var()`s each of them. The VALUES stay the theme's, so an app that re-themes a
|
|
184
|
+
* variable keeps its own; this names them and defines nothing.
|
|
185
|
+
*/
|
|
186
|
+
export const themeReadBlock = (vars: readonly string[]): string => `
|
|
187
|
+
/*
|
|
188
|
+
* Theme variables cursedbelt's components read BY NAME (\`var(--color-card)\` in a style prop).
|
|
189
|
+
* Tailwind emits a theme variable only when the app's own pass sees it used, and a \`var()\` here is
|
|
190
|
+
* that use — \`@source inline\` cannot mark one. ${vars.length} variable(s), derived by
|
|
191
|
+
* scripts/generateUtilityStyles.ts → themeVariablesReadByName(). Nothing reads this property.
|
|
192
|
+
*/
|
|
193
|
+
@layer theme {
|
|
194
|
+
:root, :host {
|
|
195
|
+
${THEME_READ_PROPERTY}: ${vars.map((v) => `var(${v})`).join(' ')};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
`;
|
|
199
|
+
|
|
127
200
|
/** The banner the artifact carries, so a reader never mistakes it for a source file. */
|
|
128
201
|
export const BANNER = `/*
|
|
129
202
|
* cursedbelt component utilities — GENERATED BUILD ARTIFACT. Never hand-edit.
|
|
@@ -177,7 +250,9 @@ export const buildUtilityStyles = async (): Promise<{ css: string; candidates: s
|
|
|
177
250
|
`${STYLES_BASE}/react exists and is not excluded by a .gitignore.`,
|
|
178
251
|
);
|
|
179
252
|
}
|
|
180
|
-
|
|
253
|
+
// The theme variables components read by name ride along: the pair's consumer scans nothing
|
|
254
|
+
// of this package either, so it has the areas' hole exactly (see themeVariablesReadByName).
|
|
255
|
+
return { css: `${BANNER}${compiled.build(candidates)}${themeReadBlock(await themeVariablesReadByName())}`, candidates };
|
|
181
256
|
};
|
|
182
257
|
|
|
183
258
|
/** The artifact's expected contents and what is on disk right now. */
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The React adapter over `beaconClient.ts` — one hook, one component.
|
|
3
|
+
*
|
|
4
|
+
* Separate file from the plain-JS half so an app with no React (or a test with
|
|
5
|
+
* no DOM) can import `reportPlace` without pulling React in, and so the app's
|
|
6
|
+
* own router stays the thing that decides what "a place" is.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect } from "react";
|
|
9
|
+
import { type ReportPlaceOptions, reportPlace, startLocationBeacon } from "./beaconClient.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Report `place` whenever it changes.
|
|
13
|
+
*
|
|
14
|
+
* `place` should be the app's own route-manifest id — the same string the
|
|
15
|
+
* server resolves against. Pass `null` while the route is unresolved (a lazy
|
|
16
|
+
* view still loading, a signed-out shell) rather than a placeholder: a
|
|
17
|
+
* placeholder becomes a real row in the console.
|
|
18
|
+
*/
|
|
19
|
+
export function useEngagementBeacon(place: string | null, options: ReportPlaceOptions = {}): void {
|
|
20
|
+
const basePath = options.basePath;
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!place) return;
|
|
23
|
+
// Fire and forget; `reportPlace` never rejects. Not awaited on purpose —
|
|
24
|
+
// a route change must not wait on telemetry.
|
|
25
|
+
void reportPlace(place, basePath ? { basePath } : {});
|
|
26
|
+
}, [place, basePath]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Report wherever the browser IS, and keep reporting as it moves.
|
|
31
|
+
*
|
|
32
|
+
* This is the form every app should use, and it is why adoption is one line
|
|
33
|
+
* rather than a per-app mapping: the client sends its LOCATION and the server
|
|
34
|
+
* resolves it against that app's own route manifest (`policy.ts:resolvePlace`).
|
|
35
|
+
* A `route.view → place id` table written in twelve app shells would be twelve
|
|
36
|
+
* places for the names to drift, and a drifted name reads on the board as a
|
|
37
|
+
* page nobody opens — a wrong answer, not a gap.
|
|
38
|
+
*
|
|
39
|
+
* Listens to `hashchange` and `popstate`, which between them cover both kinds of
|
|
40
|
+
* router — hash-routed apps like this one, and a pushState app. `reportPlace`
|
|
41
|
+
* de-duplicates, so a re-render costs nothing.
|
|
42
|
+
*/
|
|
43
|
+
export function useLocationBeacon(options: ReportPlaceOptions = {}): void {
|
|
44
|
+
const basePath = options.basePath;
|
|
45
|
+
// Delegates to the plain-JS starter so there is ONE implementation of the
|
|
46
|
+
// listener set. Most apps call `startLocationBeacon()` from `main.tsx`
|
|
47
|
+
// instead; this exists for a shell that would rather own the lifetime.
|
|
48
|
+
useEffect(() => startLocationBeacon(basePath ? { basePath } : {}), [basePath]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The location beacon as an element, for a shell that mounts declaratively.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately takes no `place` prop. Offering both would mean two callers of
|
|
55
|
+
* one module-level de-duplication memory, and an app that passed a place would
|
|
56
|
+
* silently suppress the location half or vice versa — use
|
|
57
|
+
* {@link useEngagementBeacon} directly when an app really does know its own
|
|
58
|
+
* place ids.
|
|
59
|
+
*/
|
|
60
|
+
export function EngagementBeacon(props: { basePath?: string }): null {
|
|
61
|
+
useLocationBeacon(props.basePath ? { basePath: props.basePath } : {});
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The beacon's SILENCE contract.
|
|
3
|
+
*
|
|
4
|
+
* `beacon.client.ts` says failure is silence, and on 2026-08-14 that turned out
|
|
5
|
+
* to be true only of the JavaScript. A `fetch` that comes back 401 leaves the
|
|
6
|
+
* browser's own *"Failed to load resource"* in the console however carefully the
|
|
7
|
+
* promise is handled — so a beacon fired from a sign-in card put an error on the
|
|
8
|
+
* front door of every private satellite, and `music`'s and `learn`'s signed-out
|
|
9
|
+
* route health failed on it against live prod with nothing else wrong.
|
|
10
|
+
*
|
|
11
|
+
* These tests are about the request that is NOT made. `window` is deliberately
|
|
12
|
+
* untouched: `reportPlace` is the whole mechanism and needs no DOM, which is why
|
|
13
|
+
* this file runs in the package's plain `bun test`.
|
|
14
|
+
*/
|
|
15
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { reportPlace, resetEngagementBeacon, SESSION_PATH } from "./beaconClient.js";
|
|
17
|
+
|
|
18
|
+
/** A `fetch` that records every call and answers from a script. */
|
|
19
|
+
function recordingFetch(answers: Record<string, { status?: number; body?: unknown }>) {
|
|
20
|
+
const calls: string[] = [];
|
|
21
|
+
const impl = (async (input: string) => {
|
|
22
|
+
const url = String(input);
|
|
23
|
+
calls.push(url);
|
|
24
|
+
const answer = answers[url] ?? { status: 404 };
|
|
25
|
+
const status = answer.status ?? 200;
|
|
26
|
+
return {
|
|
27
|
+
ok: status >= 200 && status < 300,
|
|
28
|
+
status,
|
|
29
|
+
json: async () => answer.body ?? {},
|
|
30
|
+
};
|
|
31
|
+
}) as unknown as typeof fetch;
|
|
32
|
+
return { impl, calls };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SIGNED_IN = { body: { authenticated: true } };
|
|
36
|
+
const SIGNED_OUT = { body: { authenticated: false } };
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
resetEngagementBeacon();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("signed out, the beacon makes no gated request at all", () => {
|
|
43
|
+
test("it asks the public session probe and then stays quiet", async () => {
|
|
44
|
+
const { impl, calls } = recordingFetch({ [SESSION_PATH]: SIGNED_OUT });
|
|
45
|
+
|
|
46
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl })).toBe(false);
|
|
47
|
+
|
|
48
|
+
// The probe, and nothing else. A POST to the gated recorder is exactly the
|
|
49
|
+
// request that produced the console line.
|
|
50
|
+
expect(calls).toEqual([SESSION_PATH]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("🔴 it does not consume the de-duplication slot", async () => {
|
|
54
|
+
// The bug this ordering prevents: a signed-out arrival at `/#/hub` marking
|
|
55
|
+
// `/#/hub` as already-reported, so the FIRST real view after signing in —
|
|
56
|
+
// which lands on that same place — is dropped forever as a duplicate.
|
|
57
|
+
const out = recordingFetch({ [SESSION_PATH]: SIGNED_OUT });
|
|
58
|
+
expect(await reportPlace("/#/hub", { fetchImpl: out.impl })).toBe(false);
|
|
59
|
+
|
|
60
|
+
resetEngagementBeacon(); // what a full page load (the SSO callback) does
|
|
61
|
+
const inn = recordingFetch({
|
|
62
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
63
|
+
"/api/engagement/view": { status: 200 },
|
|
64
|
+
});
|
|
65
|
+
expect(await reportPlace("/#/hub", { fetchImpl: inn.impl })).toBe(true);
|
|
66
|
+
expect(inn.calls).toContain("/api/engagement/view");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("signed in, nothing that used to be counted stops being counted", () => {
|
|
71
|
+
test("the view is reported, after the probe", async () => {
|
|
72
|
+
const { impl, calls } = recordingFetch({
|
|
73
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
74
|
+
"/api/engagement/view": { status: 200 },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(await reportPlace("/#/stats", { fetchImpl: impl })).toBe(true);
|
|
78
|
+
expect(calls).toEqual([SESSION_PATH, "/api/engagement/view"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the probe is asked ONCE per page load, not once per navigation", async () => {
|
|
82
|
+
const { impl, calls } = recordingFetch({
|
|
83
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
84
|
+
"/api/engagement/view": { status: 200 },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await reportPlace("/#/hub", { fetchImpl: impl });
|
|
88
|
+
await reportPlace("/#/stats", { fetchImpl: impl });
|
|
89
|
+
await reportPlace("/#/archive", { fetchImpl: impl });
|
|
90
|
+
|
|
91
|
+
expect(calls.filter((url) => url === SESSION_PATH)).toHaveLength(1);
|
|
92
|
+
expect(calls.filter((url) => url === "/api/engagement/view")).toHaveLength(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a repeated place is still one view — the old rule is untouched", async () => {
|
|
96
|
+
const { impl, calls } = recordingFetch({
|
|
97
|
+
[SESSION_PATH]: SIGNED_IN,
|
|
98
|
+
"/api/engagement/view": { status: 200 },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await reportPlace("/#/hub", { fetchImpl: impl });
|
|
102
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl })).toBe(false);
|
|
103
|
+
expect(calls.filter((url) => url === "/api/engagement/view")).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("the probe fails CLOSED", () => {
|
|
108
|
+
// One uncounted view is cheap. A false positive is the console line on the
|
|
109
|
+
// front door, which is the whole thing this path exists to prevent.
|
|
110
|
+
test.each([
|
|
111
|
+
["the probe itself errors", null],
|
|
112
|
+
["the probe answers non-2xx", { status: 500 }],
|
|
113
|
+
["the body carries no verdict", { body: {} }],
|
|
114
|
+
["the body says authenticated is a string", { body: { authenticated: "yes" } }],
|
|
115
|
+
])("%s → nothing is sent", async (_name, answer) => {
|
|
116
|
+
const impl = (async (input: string) => {
|
|
117
|
+
if (answer === null) throw new Error("network down");
|
|
118
|
+
const status = (answer as { status?: number }).status ?? 200;
|
|
119
|
+
return {
|
|
120
|
+
ok: status >= 200 && status < 300,
|
|
121
|
+
status,
|
|
122
|
+
json: async () => (answer as { body?: unknown }).body ?? {},
|
|
123
|
+
_url: input,
|
|
124
|
+
};
|
|
125
|
+
}) as unknown as typeof fetch;
|
|
126
|
+
|
|
127
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl })).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("a caller that already knows may skip the probe", () => {
|
|
132
|
+
test("assumeSignedIn reports with no second answer to the same question", async () => {
|
|
133
|
+
const { impl, calls } = recordingFetch({ "/api/engagement/view": { status: 200 } });
|
|
134
|
+
|
|
135
|
+
expect(await reportPlace("/#/hub", { fetchImpl: impl, assumeSignedIn: true })).toBe(true);
|
|
136
|
+
expect(calls).toEqual(["/api/engagement/view"]);
|
|
137
|
+
});
|
|
138
|
+
});
|