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
@@ -1,7 +1,25 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Generate `src/styles-areas/*.css` — cursedbelt's utilities, PRECOMPILED PER AREA,
4
- * so a consuming app pays for the components it renders instead of for the union.
3
+ * Generate `src/styles-areas/*.css` — cursedbelt's utility CANDIDATES, per area, so a
4
+ * consuming app pays for the components it renders instead of for the union.
5
+ *
6
+ * ── 🔴 Candidates, not compiled CSS (5.1.0, task 2124) ───────────────────────
7
+ * Until 5.1.0 each area was a PRECOMPILED, sorted utilities sheet. Tailwind emits one sorted
8
+ * utilities pass — every base utility, then every variant — so a variant always follows the
9
+ * base it overrides; two sorted passes concatenated are not sorted. The app's own pass came
10
+ * first, the areas after, and an area's `.hidden{display:none}` landed after the app's
11
+ * `.sm\:flex` at the same specificity: flix's desktop nav vanished at 1440 px and the phone
12
+ * row appeared (task 2124, measured 2026-09-23), and station hit the same on ten rules.
13
+ * Import order cannot fix it — areas first breaks cursedbelt's own `hidden sm:flex` whenever
14
+ * the app also uses `hidden`.
15
+ *
16
+ * So an area is now `@source inline("…")` of its candidates: the app's ONE Tailwind pass
17
+ * generates the area's utilities together with its own, in one sort, and the ordering bug
18
+ * cannot be written. The candidates are exactly what the precompiled sheets were built from,
19
+ * so the rules are the same rules (`src/stylesAreas.spec.ts` still proves every area together
20
+ * equals the union, rule by rule), and duplicates across areas cost nothing — one pass
21
+ * dedupes them. `scripts/checkAreaStyles.ts` fails an app build that still has a base utility
22
+ * after a variant, for whatever causes it next.
5
23
  *
6
24
  * ── The number this exists to move ──────────────────────────────────────────
7
25
  * `cursedbelt/styles.css` and the `styles-static.css` + `styles-utilities.css`
@@ -65,6 +83,7 @@
65
83
  * bun run styles:areas --check # exit 1 if any is stale, write nothing
66
84
  */
67
85
  import { REPO_ROOT, STATIC_STYLESHEET, readStylesheets } from './generateStaticStyles';
86
+ import { classesInCss } from './checkAreaStyles';
68
87
  import { UTILITY_INPUT, compileStylesheet } from './generateUtilityStyles';
69
88
  import {
70
89
  AREA_DIR,
@@ -93,6 +112,12 @@ export const areaSubpath = (name: string): string => `./${AREA_DIR.replace(/^src
93
112
  */
94
113
  export const MIN_CORE_CANDIDATES = 1_000;
95
114
 
115
+ /**
116
+ * …and the floor on what survives as UTILITIES once the scanner's non-class tokens are dropped
117
+ * (5.1.0 writes only those). 705 on the day it was set; a core of a few dozen is a broken filter.
118
+ */
119
+ export const MIN_CORE_UTILITIES = 400;
120
+
96
121
  const banner = (area: Area, candidates: number, subtracted: boolean): string => {
97
122
  const needs = transitiveNeeds(area.name);
98
123
  const imports = [CORE, ...needs, ...(subtracted ? [area.name] : [])]
@@ -100,14 +125,14 @@ const banner = (area: Area, candidates: number, subtracted: boolean): string =>
100
125
  .map((n) => ` * @import "cursedbelt/styles-areas/${n}.css";`)
101
126
  .join('\n');
102
127
  return `/*
103
- * cursedbelt — the \`${area.name}\` area. GENERATED BUILD ARTIFACT. Never hand-edit.
128
+ * cursedbelt — the \`${area.name}\` area's utility candidates. GENERATED BUILD ARTIFACT. Never hand-edit.
104
129
  *
105
130
  * ${area.why}.
106
131
  *
107
132
  * ${
108
133
  subtracted
109
134
  ? `Every Tailwind utility the modules in this area's import closure use, MINUS the\n * ones ${[CORE, ...needs].map((n) => `\`styles-areas/${n}.css\``).join(' and ')} already ship${needs.length > 0 ? '' : 's'}. 🔴 Import ALL of these, in\n * this order — this file alone is incomplete:`
110
- : 'Every Tailwind utility the primitives and their plumbing use. Import it first,\n * before any other area:'
135
+ : 'Every Tailwind utility the primitives and their plumbing use. Every app imports it:'
111
136
  }
112
137
  *
113
138
  * @import "tailwindcss";
@@ -115,9 +140,12 @@ const banner = (area: Area, candidates: number, subtracted: boolean): string =>
115
140
  ${imports}
116
141
  * @import "cursedbelt/theme.css";
117
142
  *
118
- * An app that imports every area gets exactly what \`cursedbelt/styles-utilities.css\`
119
- * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
120
- * when this file is stale. ${candidates} candidate(s) scanned.
143
+ * These are CANDIDATES (\`@source inline\`), not CSS: the app's own Tailwind pass generates
144
+ * them, sorted together with the app's utilities, so an area can never put a base utility
145
+ * after an app's responsive variant (task 2124). An app that imports every area gets exactly
146
+ * what \`cursedbelt/styles-utilities.css\` emits — proved rule by rule in
147
+ * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
148
+ * ${candidates} candidate(s).
121
149
  *
122
150
  * Written by scripts/generateAreaStyles.ts (\`bun run styles:areas\`), which
123
151
  * \`bun run build\` runs. The map of what is in which area, and why the areas are
@@ -126,6 +154,95 @@ ${imports}
126
154
  `;
127
155
  };
128
156
 
157
+ /**
158
+ * How a candidate can be written into `@source inline(…)`: inside double quotes, inside single
159
+ * quotes (one that itself holds a `"`, e.g. `before:content-[""]` — measured: Tailwind does NOT
160
+ * unescape `\"`, so a single-quoted string is the only spelling that round-trips), or not at all
161
+ * (a brace would be brace-EXPANDED, a backslash or space would split or mangle it).
162
+ */
163
+ export const inlineQuote = (candidate: string): '"' | "'" | null => {
164
+ if (/[{}\\\s]/.test(candidate)) return null;
165
+ if (!candidate.includes('"')) return '"';
166
+ return candidate.includes("'") ? null : "'";
167
+ };
168
+
169
+ /**
170
+ * The candidates worth writing: every one that can be quoted. 🔴 One that cannot is dropped only
171
+ * if it compiles to nothing — a scanner token like \`{a,b}\` is noise, but a real utility the
172
+ * string cannot carry would be a rule an app silently loses, so that throws.
173
+ */
174
+ export const inlineCandidates = async (candidates: readonly string[], area: string): Promise<string[]> => {
175
+ const unsafe = candidates.filter((c) => inlineQuote(c) === null);
176
+ if (unsafe.length > 0) {
177
+ const empty = (await compileStylesheet(UTILITY_INPUT)).build([]);
178
+ const withUnsafe = (await compileStylesheet(UTILITY_INPUT)).build(unsafe);
179
+ if (withUnsafe !== empty) {
180
+ const real: string[] = [];
181
+ for (const c of unsafe) if ((await compileStylesheet(UTILITY_INPUT)).build([c]) !== empty) real.push(c);
182
+ throw new Error(
183
+ `area \`${area}\`: ${real.length} utility candidate(s) cannot be written into @source inline(…) —\n` +
184
+ ` ${real.join('\n ')}\n An app on the areas would lose their rules. Spell the class without a brace, backslash or both quote kinds.`,
185
+ );
186
+ }
187
+ }
188
+ // Only the candidates that ARE a utility: the scanner hands over every token (`---`, `!source`,
189
+ // prop names), and the precompiled sheets simply ignored them. Keep a candidate when its own
190
+ // class is a selector subject of the compiled output — then PROVE the filter dropped nothing
191
+ // by compiling both sets and requiring identical CSS.
192
+ const writable = candidates.filter((c) => inlineQuote(c) !== null);
193
+ const full = (await compileStylesheet(UTILITY_INPUT)).build(writable);
194
+ const emitted = classesInCss(full);
195
+ // Tailwind escapes a selector the CSS.escape way; a candidate whose escaped form is in the
196
+ // output is kept even when unescaping would not round-trip it (a `…` from a doc comment did not).
197
+ // A `--token` candidate is a THEME VARIABLE the modules read (\`var(--text-sm)\` in a style
198
+ // prop): Tailwind emits it into \`:root\` only because the scan saw it, so it stays too.
199
+ const kept = writable.filter(
200
+ (c) => emitted.has(c) || full.includes(`.${cssEscape(c)}`) || (/^--[\w-]+$/.test(c) && full.includes(`${c}:`)),
201
+ );
202
+ const trimmed = (await compileStylesheet(UTILITY_INPUT)).build(kept);
203
+ if (trimmed !== full) {
204
+ throw new Error(
205
+ `area \`${area}\`: keeping only candidates that name an emitted class changed the compiled CSS ` +
206
+ `(${full.length} B → ${trimmed.length} B). Some candidate emits a rule under another selector; ` +
207
+ 'widen the filter rather than lose it.',
208
+ );
209
+ }
210
+ return kept;
211
+ };
212
+
213
+ /** `CSS.escape` for a class name — what Tailwind writes into a selector. */
214
+ export const cssEscape = (name: string): string =>
215
+ [...name]
216
+ .map((ch, i) => {
217
+ if (/[a-zA-Z0-9_-]/.test(ch) || ch.codePointAt(0)! >= 0x80) {
218
+ return i === 0 && /[0-9]/.test(ch) ? `\\${ch.codePointAt(0)!.toString(16)} ` : ch;
219
+ }
220
+ return `\\${ch}`;
221
+ })
222
+ .join('');
223
+
224
+ /** `@source inline(…)` lines, ~100 columns each, so a diff shows which candidates moved. */
225
+ export const inlineSource = (candidates: readonly string[]): string => {
226
+ const out: string[] = [];
227
+ for (const quote of ['"', "'"] as const) {
228
+ let line: string[] = [];
229
+ let width = 0;
230
+ const flush = () => {
231
+ if (line.length > 0) out.push(`@source inline(${quote}${line.join(' ')}${quote});\n`);
232
+ line = [];
233
+ width = 0;
234
+ };
235
+ for (const c of candidates) {
236
+ if (inlineQuote(c) !== quote) continue;
237
+ if (width + c.length > 96) flush();
238
+ line.push(c);
239
+ width += c.length + 1;
240
+ }
241
+ flush();
242
+ }
243
+ return out.join('');
244
+ };
245
+
129
246
  export interface BuiltArea {
130
247
  readonly name: string;
131
248
  readonly css: string;
@@ -188,11 +305,14 @@ export const buildAreaStyles = async (): Promise<BuiltArea[]> => {
188
305
  // the first a superset of the ones before it — and each file would still look
189
306
  // plausible on its own. Found by measuring; the symptom was areas growing
190
307
  // monotonically in the order they were compiled.
191
- const compiled = await compileStylesheet(UTILITY_INPUT);
308
+ const inline = await inlineCandidates(only, area.name);
309
+ if (area.name === CORE && inline.length < MIN_CORE_UTILITIES) {
310
+ throw new Error(`core kept ${inline.length} utility candidates (floor ${MIN_CORE_UTILITIES}) — the filter, not the design system, shrank.`);
311
+ }
192
312
  built.push({
193
313
  name: area.name,
194
- css: `${banner(area, only.length, area.name !== CORE)}${compiled.build(only)}`,
195
- candidates: only,
314
+ css: `${banner(area, inline.length, area.name !== CORE)}${inlineSource(inline)}`,
315
+ candidates: inline,
196
316
  modules: [...closure].map((f) => f.replace(`${REPO_ROOT}/`, '')).sort(),
197
317
  });
198
318
  }
package/scripts/paths.ts CHANGED
@@ -70,7 +70,7 @@ function viaWorktree(from: string): string | null {
70
70
  });
71
71
  if (git.status !== 0) return null;
72
72
  const gitDir = (git.stdout ?? "").trim();
73
- if (!gitDir || !gitDir.startsWith("/")) return null;
73
+ if (!gitDir?.startsWith("/")) return null;
74
74
  // `<primary>/.git` → `<primary>`, then the ordinary walk up.
75
75
  return findForgeRoot(dirname(gitDir));
76
76
  } catch {
@@ -130,6 +130,8 @@ export const AREAS: readonly Area[] = [
130
130
  'colorScheme',
131
131
  'constants',
132
132
  'layout',
133
+ // The engagement beacon draws nothing; it is here because every app beacons (5.2.0).
134
+ 'engagement',
133
135
  'namespace',
134
136
  'portal',
135
137
  'slots',
@@ -104,6 +104,7 @@ describe('FooterSlot — a footer can no longer render without chrome', () => {
104
104
  const { container } = render(
105
105
  <FooterSlot
106
106
  footer={
107
+ // biome-ignore lint/complexity/noUselessFragments: the fragment IS the case under test
107
108
  <>
108
109
  <ActionBar primary={<Button tooltip='Save'>Save</Button>} />
109
110
  </>
@@ -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
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `cursedbelt/react/engagement-beacon` — the engagement beacon's plain-JS half (no React). Lifted
3
+ * 2026-09-23 (cursedbelt 5.2.0, task 098-280's browser half) from `src/kit/engagement/beacon.client.ts`,
4
+ * identical in collections, family, music and roms (auth's differed only in a comment). The
5
+ * server half is `cursedbelt-server/engagement`.
6
+ *
7
+ * The browser half — report which PLACE the user is on, once per arrival.
8
+ *
9
+ * ── Why an explicit beacon and not the request log ──────────────────────────
10
+ * Most satellites are SPA-routed: moving from Notes' Files page to its Ask page
11
+ * fires zero navigations and, on a warm cache, zero HTTP requests. So the
12
+ * request metrics already in `metrics/` can tell you an app is used and cannot
13
+ * tell you which of its pages are — which is exactly the question the owner
14
+ * asked ("notes is used but its slideshow is not"). One small event, sent by
15
+ * the router, is the only thing that can answer it.
16
+ *
17
+ * ── It is ONE implementation, adopted; never per-app ────────────────────────
18
+ * Eleven hand-rolled beacons would be eleven different place vocabularies, and
19
+ * the console would be comparing strings that mean different things. Apps call
20
+ * `reportPlace` (or mount `useEngagementBeacon`) and send an id from their own
21
+ * route manifest; the server resolves it against that same manifest.
22
+ *
23
+ * ── What it does NOT send ───────────────────────────────────────────────────
24
+ * No path, no query, no title, no referrer, no identifiers of any kind — just
25
+ * `{ place }`. The account comes from the session cookie the request already
26
+ * carries, server-side. There is nothing in the payload that could carry a note
27
+ * id, a ROM title or a search string, which is the property that makes this
28
+ * safe to run inside private apps at all. See `policy.ts`.
29
+ *
30
+ * ── Failure is silence ──────────────────────────────────────────────────────
31
+ * Every call is fire-and-forget and swallows its error. Telemetry that can
32
+ * surface an error toast, block a route change, or reject an unhandled promise
33
+ * is telemetry that makes the app worse than having none.
34
+ *
35
+ * 🔴 …and "silence" includes the BROWSER's console, which is why the beacon
36
+ * asks whether there is a session before it reports (`SESSION_PATH` below).
37
+ * Swallowing the rejection in JS is not enough: a `fetch` that comes back 401
38
+ * leaves the browser's own *"Failed to load resource: the server responded with
39
+ * a status of 401"* in the console no matter what the caller does with the
40
+ * promise. Measured 2026-08-14 against live prod — `music` and `learn` both
41
+ * failed their signed-out route-health check on exactly this, with a correctly
42
+ * rendered sign-in card on screen and nothing else wrong. `apps/roms`' App.tsx
43
+ * already wrote the rule down for the feedback provider: *"an app that logs an
44
+ * error on its front door is an app nobody can tell apart from a broken one."*
45
+ * The recorder's 401 is deliberate and stays — `api.ts` mounts it AFTER the
46
+ * session gate on purpose, because the gate is what puts the user where
47
+ * `resolveUser` can read them — so the fix belongs on this side.
48
+ *
49
+ * It is also the semantically right answer, not merely a quieter one: the
50
+ * recorder attributes a view to an account, so a signed-out arrival could never
51
+ * have been recorded. Nothing that used to be counted stops being counted.
52
+ */
53
+
54
+ /** Where the recorder is mounted. Apps that remount it pass `basePath`. */
55
+ export const ENGAGEMENT_BASE_PATH = "/api/engagement";
56
+
57
+ /**
58
+ * The public session probe every satellite serves
59
+ * (`ssoConsumer.server.ts` → `{ authenticated, user?, accountsUrl }`).
60
+ *
61
+ * Deliberately NOT session-gated on the server, which is what makes it usable
62
+ * here: asking it costs one small same-origin GET and can never itself produce
63
+ * the console line this exists to avoid.
64
+ */
65
+ export const SESSION_PATH = "/api/auth/session";
66
+
67
+ export interface ReportPlaceOptions {
68
+ basePath?: string;
69
+ /** Injected in tests; defaults to the global. */
70
+ fetchImpl?: typeof fetch;
71
+ /** Override the session probe. Apps that remount the consumer elsewhere. */
72
+ sessionPath?: string;
73
+ /**
74
+ * Skip the session probe and report unconditionally.
75
+ *
76
+ * For a caller that ALREADY knows there is a session — a React tree below the
77
+ * authenticated branch, say — where the probe would be a second answer to a
78
+ * question already asked. Never set it from `main.tsx`, which runs before
79
+ * anything knows.
80
+ */
81
+ assumeSignedIn?: boolean;
82
+ }
83
+
84
+ /**
85
+ * The last place reported, so a router that re-renders does not re-report.
86
+ *
87
+ * A React router re-runs its effects on every state change that touches the
88
+ * route object, and a beacon per keystroke in a search box would turn one visit
89
+ * into forty views — inflating exactly the number the console reads as
90
+ * "interest". De-duplicating on the place id is what keeps a view meaning "an
91
+ * arrival".
92
+ */
93
+ let lastReported: string | null = null;
94
+
95
+ /**
96
+ * Where the browser is, as a string the server can resolve.
97
+ *
98
+ * The hash when there is one (every satellite but roms routes on it), else the
99
+ * pathname. Never the search string: `normalizePlaceId` strips one anyway, and
100
+ * not sending it in the first place means user text does not travel at all.
101
+ */
102
+ export function currentLocationPlace(): string {
103
+ const { hash, pathname } = window.location;
104
+ const raw = hash && hash !== "#" ? hash : pathname;
105
+ return raw.split("?")[0] ?? "/";
106
+ }
107
+
108
+ /**
109
+ * The answer to "is anyone signed in?", remembered for this page load.
110
+ *
111
+ * One probe per load, not one per navigation: the answer cannot change without
112
+ * a navigation that replaces the document (the SSO callback is a full page
113
+ * load), and re-asking on every hash change would trade one wasted request for
114
+ * dozens.
115
+ */
116
+ let sessionAnswer: Promise<boolean> | null = null;
117
+
118
+ /** Reset the de-duplication memory. Tests, and a sign-out. */
119
+ export function resetEngagementBeacon(): void {
120
+ lastReported = null;
121
+ sessionAnswer = null;
122
+ }
123
+
124
+ /**
125
+ * Is there a session? Silent, cached, and false on any doubt.
126
+ *
127
+ * Fails CLOSED — a probe that throws, answers non-2xx or returns a body this
128
+ * cannot read means "do not report". The cost of a false negative is one
129
+ * uncounted view; the cost of a false positive is the console line on the front
130
+ * door that this whole path exists to prevent.
131
+ */
132
+ async function hasSession(options: ReportPlaceOptions): Promise<boolean> {
133
+ const doFetch = options.fetchImpl ?? globalThis.fetch;
134
+ if (typeof doFetch !== "function") return false;
135
+ try {
136
+ const res = await doFetch(options.sessionPath ?? SESSION_PATH, {
137
+ credentials: "same-origin",
138
+ });
139
+ if (!res.ok) return false;
140
+ const body = (await res.json()) as { authenticated?: unknown } | null;
141
+ return body?.authenticated === true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Report an arrival at `place`. Returns whether anything was sent.
149
+ *
150
+ * Not awaited by callers in practice — the promise is returned so a test can
151
+ * settle it without a timer.
152
+ */
153
+ export async function reportPlace(
154
+ place: string,
155
+ options: ReportPlaceOptions = {},
156
+ ): Promise<boolean> {
157
+ if (!place || place === lastReported) return false;
158
+ const doFetch = options.fetchImpl ?? globalThis.fetch;
159
+ if (typeof doFetch !== "function") return false;
160
+
161
+ // 🔴 BEFORE the de-duplication is committed, not after. A signed-out arrival
162
+ // must leave no trace at all — recording it as `lastReported` would mean the
163
+ // view that happens once the visitor signs in and lands back on this same
164
+ // place is silently dropped as a duplicate.
165
+ if (options.assumeSignedIn !== true) {
166
+ sessionAnswer ??= hasSession(options);
167
+ if (!(await sessionAnswer)) return false;
168
+ }
169
+
170
+ if (place === lastReported) return false;
171
+ lastReported = place;
172
+ try {
173
+ await doFetch(`${options.basePath ?? ENGAGEMENT_BASE_PATH}/view`, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json" },
176
+ // Same-origin credentials: the session cookie is the only identity
177
+ // this request carries, and it is the only one it may carry.
178
+ credentials: "same-origin",
179
+ body: JSON.stringify({ place }),
180
+ keepalive: true,
181
+ });
182
+ return true;
183
+ } catch {
184
+ // See the header: telemetry never becomes the user's problem.
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /** Detaches the listeners `startLocationBeacon` installed. */
190
+ export type StopBeacon = () => void;
191
+
192
+ let running: StopBeacon | null = null;
193
+
194
+ /**
195
+ * Start reporting wherever the browser is, and keep reporting as it moves.
196
+ *
197
+ * The form every app adopts, called once from `main.tsx` beside
198
+ * `initColorScheme()` — one line, no React, and no per-app mapping from a
199
+ * router's internal view name to a place id. The server resolves the LOCATION
200
+ * against that app's own `routes.manifest.json` (`policy.ts:resolvePlace`),
201
+ * which is the list the app already maintains and already gates on.
202
+ *
203
+ * `hashchange` and `popstate` between them cover both kinds of router, which is
204
+ * why BOTH are listened for when `collections` — hash-routed — only needs the
205
+ * first. The census that settled it was the previous generation's twelve apps:
206
+ * eleven hash routers and one pushState app. Keeping the second listener costs
207
+ * a no-op call, and losing it would be silent.
208
+ *
209
+ * Idempotent — a second call replaces the first rather than doubling every
210
+ * view, which matters because React 18 StrictMode mounts twice in development
211
+ * and a doubled count is a wrong number rather than a visible fault.
212
+ */
213
+ export function startLocationBeacon(options: ReportPlaceOptions = {}): StopBeacon {
214
+ running?.();
215
+ const send = (): void => {
216
+ void reportPlace(currentLocationPlace(), options);
217
+ };
218
+ send();
219
+ window.addEventListener("hashchange", send);
220
+ window.addEventListener("popstate", send);
221
+ const stop: StopBeacon = () => {
222
+ window.removeEventListener("hashchange", send);
223
+ window.removeEventListener("popstate", send);
224
+ if (running === stop) running = null;
225
+ };
226
+ running = stop;
227
+ return stop;
228
+ }
@@ -0,0 +1,13 @@
1
+ // `cursedbelt/react/engagement-beacon` — the engagement beacon: `reportPlace` and
2
+ // `startLocationBeacon` (plain JS, no React) and the React hooks over them.
3
+ export {
4
+ ENGAGEMENT_BASE_PATH,
5
+ type ReportPlaceOptions,
6
+ reportPlace,
7
+ resetEngagementBeacon,
8
+ SESSION_PATH,
9
+ type StopBeacon,
10
+ startLocationBeacon,
11
+ currentLocationPlace,
12
+ } from './beaconClient.js';
13
+ export { EngagementBeacon, useEngagementBeacon, useLocationBeacon } from './beacon.js';