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
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Generate `src/styles-areas/*.css` — cursedbelt's
|
|
4
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
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,
|
|
195
|
-
candidates:
|
|
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
|
|
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 {
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -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';
|