@uniweb/core 0.9.0 → 0.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,6 +9,7 @@
9
9
  "./data-paths": "./src/data-paths.js",
10
10
  "./detail-url": "./src/detail-url.js",
11
11
  "./fetch-config": "./src/fetch-config.js",
12
+ "./icon-corpus": "./src/icon-corpus.js",
12
13
  "./locale-config": "./src/locale-config.js",
13
14
  "./route-match": "./src/route-match.js",
14
15
  "./section-id": "./src/section-id.js",
@@ -39,7 +40,7 @@
39
40
  "vitest": "^4.1.7"
40
41
  },
41
42
  "dependencies": {
42
- "@uniweb/semantic-parser": "^1.2.2",
43
+ "@uniweb/semantic-parser": "^1.2.3",
43
44
  "@uniweb/theming": "^0.1.15"
44
45
  },
45
46
  "scripts": {
package/src/block.js CHANGED
@@ -5,8 +5,12 @@
5
5
  * child blocks, and state management. Connects to foundation components.
6
6
  */
7
7
 
8
- import { parseContent as parseSemanticContent } from '@uniweb/semantic-parser'
8
+ import {
9
+ parseContent as parseSemanticContent,
10
+ resolveAssetUrl
11
+ } from '@uniweb/semantic-parser'
9
12
  import { normalizeTokenValue } from '@uniweb/theming'
13
+ import { sectionDomId } from './section-id.js'
10
14
 
11
15
  /**
12
16
  * Lift container fences out of a content document.
@@ -156,7 +160,7 @@ export default class Block {
156
160
  if (rawBg && !this.standardOptions.background) {
157
161
  this.standardOptions = {
158
162
  ...this.standardOptions,
159
- background: Block.normalizeBackground(rawBg)
163
+ background: Block.normalizeBackground(rawBg, this.parseOptions())
160
164
  }
161
165
  }
162
166
 
@@ -285,6 +289,28 @@ export default class Block {
285
289
  * block already knows both — a foundation should not have to thread context
286
290
  * it was handed. Same arrangement as `useFormSubmit({ block })`.
287
291
  *
292
+ * ## ⭐ `section` and `section_id` answer DIFFERENT questions — both ride
293
+ *
294
+ * `section` is the component **type** (`Hero`); `section_id` is this
295
+ * **instance** (`section-hero`), the same string the renderers write as the
296
+ * DOM id, so it joins to the anchor a search result already links to.
297
+ *
298
+ * | | cardinality to a collector | survives a foundation swap | survives a content rename |
299
+ * |---|---|---|---|
300
+ * | `section` (type) | the foundation's vocabulary — bounded, small | ⛔ no | ✅ yes |
301
+ * | `section_id` (instance) | pages × sections — needs scoping to be storable | ✅ yes | ⛔ **no — a rename silently splits the series** |
302
+ *
303
+ * ⛔ **Both are sent, deliberately, and dropping either later is a wire
304
+ * break.** A consumer storing only one is free to ignore the other — the cost
305
+ * of carrying it is one field — whereas **collecting under an identity that is
306
+ * later changed throws the data away rather than merely delaying it.**
307
+ * *(Agreed across the producing and serving sides, 2026-08-17; the cardinality
308
+ * numbers that are the reason are recorded internally.)*
309
+ *
310
+ * ⚠️ Instance identity on the wire is **`(path, section_id)`** — `path` is
311
+ * already here, so no `path#section` composite is ever sent and neither side
312
+ * keeps one in sync.
313
+ *
288
314
  * ⛔ **No guard is needed at the call site.** A site with no tracking
289
315
  * destination is the default: the call returns having done nothing, opened no
290
316
  * connection and thrown nothing. Absent is the normal state, not an error.
@@ -302,6 +328,7 @@ export default class Block {
302
328
  globalThis.uniweb?.tracking?.track(event, {
303
329
  path: this.path,
304
330
  section: this.type,
331
+ section_id: sectionDomId(this),
305
332
  ...data
306
333
  })
307
334
  }
@@ -377,10 +404,43 @@ export default class Block {
377
404
  * Uses @uniweb/semantic-parser for intelligent content extraction
378
405
  * Returns flat content structure
379
406
  */
407
+ /**
408
+ * Options handed to the semantic parser for this block.
409
+ *
410
+ * `assets` carries the host's asset-URL pattern (`config.assets.url`) so a
411
+ * node's `assetId`/`assetExt` resolve to a real URL. It is read from the
412
+ * published payload and passed as an explicit input rather than reached for
413
+ * as module state — the parser must never know a host, and a pattern is the
414
+ * only thing that tells it one.
415
+ *
416
+ * ⚠️ This depends on `website.config` being populated before a Block is
417
+ * constructed, and it is — but only because `Page.bodyBlocks` is a LAZY
418
+ * getter, so blocks are built at render, long after the Website constructor
419
+ * returns. The constructor itself assigns `this.pages` (line ~149) BEFORE
420
+ * `this.config` (line ~159), so an eager Block would parse against an empty
421
+ * config and every asset would silently fall through to `src` — working
422
+ * output, no error, no resolution.
423
+ *
424
+ * ⚠️ **That getter now has a SECOND consumer, in another lane.** The editor
425
+ * relies on it re-running: `updateParams` → `website.rebuild` → `_applyContent`
426
+ * → `page.bodyBlocks` constructs fresh Blocks, which is what re-normalises a
427
+ * section background on every live edit (traced by the frontend lane,
428
+ * 2026-08-17, before deleting their own duplicate normaliser). So making block
429
+ * construction eager breaks background editing as well as asset resolution —
430
+ * two failures, two lanes, one cause, and neither visible from the other side.
431
+ *
432
+ * Do not make block construction eager
433
+ * without moving the config assignment first.
434
+ */
435
+ parseOptions() {
436
+ const assets = this.website?.config?.assets
437
+ return assets ? { assets } : {}
438
+ }
439
+
380
440
  extractFromProseMirror(doc) {
381
441
  try {
382
442
  // Parse with semantic-parser - returns flat structure
383
- const parsed = parseSemanticContent(doc)
443
+ const parsed = parseSemanticContent(doc, this.parseOptions())
384
444
 
385
445
  // Parsed content is now flat: { title, pretitle, paragraphs, links, items, sequence, ... }
386
446
  return parsed
@@ -678,9 +738,45 @@ export default class Block {
678
738
  * - Object without mode: mode inferred from which fields are present
679
739
  *
680
740
  * @param {string|Object} raw - Raw background value from frontmatter
741
+ * @param {Object} [options] - Parse options; `options.assets.url` is the host's
742
+ * asset-URL pattern, used to resolve a store-held background.
681
743
  * @returns {Object} Normalized background config with mode
682
744
  */
683
- static normalizeBackground(raw) {
745
+ static normalizeBackground(raw, options) {
746
+ return Block.resolveBackgroundMedia(Block.normalizeBackgroundShape(raw), options)
747
+ }
748
+
749
+ /**
750
+ * Resolve a store-held background asset (`assetId` + `assetExt`) to a `src`.
751
+ *
752
+ * ⭐ This runs HERE, at normalize time, and deliberately not at render. A
753
+ * background is drawn by two twinned implementations — `Background.jsx` (SPA)
754
+ * and `ssr-renderer.js` (SSG + edge) — which both read `background.image?.src`.
755
+ * Resolving at render would mean the identical change in both, and the twins
756
+ * drifting is this repo's standing hazard: the lane you tested keeps working
757
+ * while the other is wrong in production. One resolution here, and both lanes
758
+ * get it for free.
759
+ *
760
+ * Same precedence as the node path: a store-held asset wins WHEN IT RESOLVES,
761
+ * so a producer may write `assetId` beside a `src` and the `src` carries the
762
+ * render until a host declares a pattern.
763
+ */
764
+ static resolveBackgroundMedia(bg, options) {
765
+ const pattern = options?.assets?.url
766
+ if (!pattern || !bg || typeof bg !== 'object') return bg
767
+
768
+ let out = bg
769
+ for (const key of ['image', 'video']) {
770
+ const media = bg[key]
771
+ if (!media || typeof media !== 'object') continue
772
+ const url = resolveAssetUrl(media.assetId, media.assetExt, pattern)
773
+ if (url) out = { ...out, [key]: { ...media, src: url } }
774
+ }
775
+ return out
776
+ }
777
+
778
+ /** Shape normalization only — no resolution. See `normalizeBackground`. */
779
+ static normalizeBackgroundShape(raw) {
684
780
  // String shorthand — classify by content
685
781
  if (typeof raw === 'string') {
686
782
  // URL or path → image/video
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The icon corpus — its default origin and its filename rule.
3
+ *
4
+ * ## Why this is one module and not five constants
5
+ *
6
+ * An icon referenced by `library` + `name` is **our own asset**, not the site's.
7
+ * We publish the families, we document them, and `@uniweb/icons`'
8
+ * `scripts/build-cdn.js` writes the files. So unlike a site asset — whose URL
9
+ * pattern the HOST declares because the bytes are in the host's store — the
10
+ * layout here is ours to name, and a default origin is the correct answer
11
+ * rather than a guessed one.
12
+ *
13
+ * That makes this a **writer/reader pair**, which is the part that needs a
14
+ * single definition:
15
+ *
16
+ * writer @uniweb/icons scripts/build-cdn.js emits cdn/{family}/{family}-{name}.svg
17
+ * readers @uniweb/runtime setup.js browser resolution
18
+ * @uniweb/runtime ssr-renderer.js prerender + Worker isolate prefetch
19
+ * @uniweb/icons src/resolver.js local-then-CDN resolution
20
+ *
21
+ * Before 2026-08-17 the origin was spelled out in three of those and the
22
+ * filename rule in all four. A writer and its readers drifting is the exact
23
+ * defect `@uniweb/core/route-match` exists to prevent, and the one the runtime
24
+ * channel's bridge-filename helper prevents by construction. Same treatment
25
+ * here: one helper, no second spelling.
26
+ *
27
+ * ## ⛔ Keep this a LEAF — zero imports
28
+ *
29
+ * `ssr-renderer.js` is bundled into the SSR isolate that runs in a Cloudflare
30
+ * Worker, so anything it reaches must import nothing: no `node:*`, no DOM, no
31
+ * `@uniweb/core` root (which pulls semantic-parser and theming). That is the
32
+ * same constraint `route-match` and `locale-config` carry, and the reason this
33
+ * lives in core rather than in `@uniweb/icons` — a Worker cannot take a package
34
+ * whose value is ~3,200 icon modules behind a dynamic import, and `@uniweb/runtime`
35
+ * depends on core already.
36
+ *
37
+ * A host may override the ORIGIN — a mirror of this corpus is a legitimate
38
+ * deployment choice, and on a hosted site the base comes from the payload the
39
+ * host serves. It may not override the LAYOUT: a mirror mirrors. Re-deriving
40
+ * filenames instead of copying them is what produced two incompatible spellings
41
+ * of the same corpus once already.
42
+ *
43
+ * @module @uniweb/core/icon-corpus
44
+ */
45
+
46
+ /**
47
+ * Where the framework publishes its own icon corpus.
48
+ *
49
+ * Not a fallback for a missing host address — it is the address of OUR artifact,
50
+ * and it is what makes `![](lu-house)` work in a project with no backend at all.
51
+ * A host that mirrors the corpus supplies its own origin on the payload.
52
+ */
53
+ export const DEFAULT_ICON_BASE = 'https://uniweb.github.io/icons'
54
+
55
+ /**
56
+ * The corpus path for one icon, relative to any origin serving it.
57
+ *
58
+ * `{family}/{family}-{name}.svg` — the family repeats deliberately: the
59
+ * directory groups, and the filename prefix keeps ids unique across families so
60
+ * a name alone is never ambiguous.
61
+ *
62
+ * @param {string} family - short family code (`lu`, `hi2`, `fa6`)
63
+ * @param {string} name - icon id within that family (`house`, `a-arrow-down`)
64
+ * @returns {string} e.g. `lu/lu-house.svg`
65
+ */
66
+ export function iconPath(family, name) {
67
+ return `${family}/${family}-${name}.svg`
68
+ }
69
+
70
+ /**
71
+ * The full URL for one icon against a serving origin.
72
+ *
73
+ * @param {string} family - short family code
74
+ * @param {string} name - icon id within that family
75
+ * @param {string} [base] - serving origin; defaults to the framework's own
76
+ * @returns {string}
77
+ */
78
+ export function iconUrl(family, name, base = DEFAULT_ICON_BASE) {
79
+ return `${String(base).replace(/\/+$/, '')}/${iconPath(family, name)}`
80
+ }
@@ -2,7 +2,7 @@
2
2
  * Shared locale-config helpers — the ONE home for the language rules that
3
3
  * build, sync, runtime, and the CLI all apply to a site's config.
4
4
  *
5
- * Contract (kb/framework/build/uwx-format.md → "Per-locale publish readiness"):
5
+ * Contract ("Per-locale publish readiness"):
6
6
  * - `languages` (site.yml) / `info.languages` (wire) — the DECLARED working
7
7
  * set. A plain, strongly-validated string list.
8
8
  * - `publishLanguages` (site.yml) / `info.publish_languages` (wire) — publish
package/src/page.js CHANGED
@@ -48,6 +48,14 @@ export default class Page {
48
48
  // Rewrite target (if set, this route is served by an external site)
49
49
  this.rewrite = pageData.rewrite || null
50
50
 
51
+ // Opt this page into section-level instrumentation (`trackSections` in
52
+ // page.yml). Off by default, and PAGE-LEVEL ONLY — there is deliberately no
53
+ // site-wide or folder-level spelling, because one event per section on every
54
+ // page of a site produces far more distinct values than an analytics
55
+ // consumer can reasonably retain. The runtime reads it to decide whether to
56
+ // arm an observer; it means nothing without a tracking destination.
57
+ this.trackSections = pageData.trackSections || false
58
+
51
59
  // Two orthogonal visibility axes:
52
60
  // • `hidden` — REACHABILITY. When true the page is excluded from the published
53
61
  // site entirely (the build prunes it and its subtree); it survives only in
package/src/services.js CHANGED
@@ -180,26 +180,62 @@ export function resolveService(website, name) {
180
180
  }
181
181
 
182
182
  /**
183
- * Read a service's declaration object, whichever tier supplied it.
183
+ * Read a service's options, filling each key from the first tier that declares
184
+ * it — **the site's value wins per key, and the host fills the gaps.**
184
185
  *
185
186
  * `resolveService` answers *where*; this answers *with what options*. Only the
186
187
  * object form carries any — a shorthand string is an address and nothing else.
187
- * Used by `tracking:` for `consent:`; a future service with its own options
188
- * reads them the same way rather than inventing a second lookup.
189
188
  *
190
- * The tiers are checked in the same order and for the same reason, so a site
191
- * that authors the object form is not silently merged with a host's.
189
+ * ## Why per-key rather than all-or-nothing
190
+ *
191
+ * This used to return the site's object whole whenever the site declared
192
+ * *anything*, so a single authored key hid every option the host offered. That
193
+ * put the two readers in this file on different rules, and the disagreement was
194
+ * not cosmetic:
195
+ *
196
+ * - `resolveService` already falls through **per key** — a site declaration
197
+ * carrying no `endpoint` lets the host's endpoint answer.
198
+ * - `readServiceOptions` fell through **not at all**.
199
+ *
200
+ * ⇒ A site declaring only `tracking: { tags: [...] }` therefore kept sending to
201
+ * the **host's** endpoint while discarding the **host's** `consent` setting —
202
+ * using someone's collector while ignoring their gate. Not a corner case: it is
203
+ * what an operator gets by turning on a third-party tag while their host
204
+ * supplies the collector.
205
+ *
206
+ * One rule now covers both readers, and it is the one a reader of two-tier
207
+ * config already expects: the more specific tier wins where it speaks, and says
208
+ * nothing where it is silent.
209
+ *
210
+ * ⚖️ **Consequence worth stating, because it decides a question that would
211
+ * otherwise need its own rule:** a host's `consent` applies only when the site
212
+ * declared none. That is the host *filling a gap*, never overriding an
213
+ * operator's decision — so there is no "most restrictive wins" special case,
214
+ * and an operator who wants no gate on a host that asks for one writes
215
+ * `consent: none` and is done.
216
+ *
217
+ * ⚠️ **The merge is shallow and deliberately so.** Keys replace, they do not
218
+ * combine: a site's `tags` replaces a host's rather than concatenating with it.
219
+ * Combining would make the result depend on what a host happens to offer, which
220
+ * is precisely the unpredictability a site's own config should not have.
192
221
  *
193
222
  * @param {object} website
194
223
  * @param {string} name
195
- * @returns {object} the declaration object, or `{}` when there is none
224
+ * @returns {object} the effective options, or `{}` when no tier declares any
196
225
  */
197
226
  export function readServiceOptions(website, name) {
198
227
  const config = website?.config
199
- const authored = config?.[name]
200
- if (authored !== undefined) {
201
- return authored && typeof authored === 'object' ? authored : {}
202
- }
203
- const hosted = config?.services?.[name]
204
- return hosted && typeof hosted === 'object' ? hosted : {}
228
+ return { ...asOptions(config?.services?.[name]), ...asOptions(config?.[name]) }
229
+ }
230
+
231
+ /**
232
+ * A declaration contributes options only in its object form. A string is an
233
+ * address, an array is malformed, and neither carries a key worth spreading.
234
+ *
235
+ * @param {*} declaration
236
+ * @returns {object}
237
+ */
238
+ function asOptions(declaration) {
239
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return {}
240
+ return declaration
205
241
  }
package/src/tracker.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * ⭐ **A page visit is a trackable event.** That sentence is the design. There is
5
5
  * one destination, one envelope, and one queue; the runtime emits `page_view`
6
6
  * automatically and a foundation emits whatever else it likes, through the same
7
- * path. Design doc: `kb/framework/plans/tracking.md`.
7
+ * path.
8
8
  *
9
9
  * ```
10
10
  * { event: 'page_view', path: '/about', referrer?, utm_* }
@@ -71,6 +71,30 @@ const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefine
71
71
  * loudly.
72
72
  *
73
73
  * A cross-origin parent throws on `window.top` access; that also means framed.
74
+ *
75
+ * ⚠️ **The boundary condition: this checks `framed`, not `preview`.** A preview
76
+ * rendered in a popup or a top-level tab would be same-origin and NOT framed,
77
+ * `isLiveDocument()` would return true, and — like every failure in this area —
78
+ * it would have no symptom: the payload stays internally consistent while the
79
+ * owner's own edits quietly inflate their numbers.
80
+ *
81
+ * ✅ **Covered on the other side too, by a test rather than a promise**
82
+ * **[frontend, measured; relayed 2026-08-17]**: `preview-runtime` imports the
83
+ * real `setup` / `provider` / `foundation-loader` (not a fork), all five of its
84
+ * mounts are `<iframe>`, and the popup case is already instantiated —
85
+ * `DetachedPreview.jsx` is a popup *root* with the runtime in an iframe inside
86
+ * it, so the guard holds. A non-framed preview is not reachable by re-parenting
87
+ * at all: content and foundation arrive *only* over the frame-bridge, so going
88
+ * non-framed means replacing the transport. Their
89
+ * `previewFraming.smoke.test.js` pins both the iframe property and the exact set
90
+ * of mounting files, so a new mount fails there.
91
+ *
92
+ * ⛔ **Do not "fix" this by adding an explicit preview flag from the harness.**
93
+ * A suppression that depends on another lane remembering something has no
94
+ * symptom when forgotten, which is the whole reason this one reads the DOM
95
+ * instead; two suppressions where one is authoritative is how you get a stale
96
+ * one. The two guards now fail independently, in different repos, for the same
97
+ * violation.
74
98
  */
75
99
  function detectFramed() {
76
100
  if (!isBrowser) return false
@@ -160,8 +184,11 @@ export default class Tracker {
160
184
  // 'granted' | 'denied' | 'pending'. Without a consent requirement the
161
185
  // operator's act of declaring a destination IS the decision, and the
162
186
  // framework does not presume a jurisdiction on their behalf.
187
+ // ⛔ The requirement is consumed HERE and not stored. A `consentRequired`
188
+ // field was kept alongside and read by nothing — dead instance state in a
189
+ // class every site loads, which is what `destroy()` and its three fields
190
+ // were removed for. The starting status is the whole of what the flag means.
163
191
  this.consent = options.consentRequired ? 'pending' : 'granted'
164
- this.consentRequired = !!options.consentRequired
165
192
 
166
193
  this.queue = []
167
194
  this.acquisition = null
@@ -180,11 +207,17 @@ export default class Tracker {
180
207
  // reports three times.
181
208
  this.currentPath = null
182
209
 
183
- this.flushIntervalId = null
184
- this.onPageHide = null
185
- this.onVisibilityChange = null
186
210
  this.framed = detectFramed()
187
211
 
212
+ // Called once when consent moves to granted, and never otherwise. The
213
+ // runtime uses it to load a site's declared third-party tags at the moment
214
+ // they become permitted; nothing in core knows or cares what it does.
215
+ //
216
+ // ⛔ Declared HERE because the instance is sealed below — an assignment to
217
+ // an undeclared property throws in module code, and the caller assigns this
218
+ // after construction. Same reason `Uniweb.defaultInsets` is pre-declared.
219
+ this.onGranted = null
220
+
188
221
  if (isBrowser && this.isEnabled()) {
189
222
  this.acquisition = captureAcquisition()
190
223
  // Minted even when consent is pending: events buffered before the visitor
@@ -200,14 +233,30 @@ export default class Tracker {
200
233
  }
201
234
 
202
235
  /**
203
- * Enabled means: a destination exists, we are in a browser, and we are not
204
- * inside someone's iframe. Consent is checked separately a consent-pending
205
- * tracker is *enabled* and buffering, which is a different state from off.
236
+ * Whether this document is one where the site's telemetry should run at all
237
+ * a real visit in a browser, rather than a server render or a framed
238
+ * authoring preview. Says nothing about whether anything is *configured*.
239
+ *
240
+ * Split out from `isEnabled()` because a second consumer needs exactly this
241
+ * half: the runtime loads a site's declared third-party tags, which have no
242
+ * endpoint of ours to check but must be suppressed in the same contexts and
243
+ * for the same reason. One predicate, so the two cannot drift.
244
+ *
245
+ * @returns {boolean}
246
+ */
247
+ isLiveDocument() {
248
+ return isBrowser && !this.framed
249
+ }
250
+
251
+ /**
252
+ * Enabled means: a destination exists, and this is a live document. Consent
253
+ * is checked separately — a consent-pending tracker is *enabled* and
254
+ * buffering, which is a different state from off.
206
255
  *
207
256
  * @returns {boolean}
208
257
  */
209
258
  isEnabled() {
210
- return !!this.endpoint && isBrowser && !this.framed
259
+ return !!this.endpoint && this.isLiveDocument()
211
260
  }
212
261
 
213
262
  /** @returns {'granted'|'denied'|'pending'} */
@@ -223,15 +272,44 @@ export default class Tracker {
223
272
  * decision, and the views that preceded the click are not lost. Denying
224
273
  * discards the buffer and stops accepting.
225
274
  *
275
+ * ⛔ **Recording the decision is NOT gated on `isEnabled()`, deliberately.**
276
+ * Consent is *the visitor's answer*; enablement is *whether we have anywhere
277
+ * to send*. Two different questions, and conflating them meant a decision
278
+ * could not be recorded **when no destination resolved** — benign while our
279
+ * own queue is the only thing gated on consent, a correctness bug the moment
280
+ * anything else is.
281
+ *
282
+ * The same conflation suppressed recording inside a framed document. That
283
+ * suppression exists so an authoring session cannot inflate a site's own
284
+ * numbers (see `detectFramed`), and it belongs on the *sending*: with
285
+ * `consentRequired` the status starts `'pending'` and could not move at all,
286
+ * so a banner following the documented pattern would render and then never
287
+ * dismiss.
288
+ *
289
+ * Nothing is sent as a result: `flush()` keeps its own `isEnabled()` guard,
290
+ * so a disabled tracker still transmits nothing no matter what is recorded
291
+ * here. This changes what the tracker *remembers*, never what it *emits*.
292
+ *
226
293
  * @param {boolean} granted
227
294
  */
228
295
  setConsent(granted) {
229
- if (!this.isEnabled()) return
296
+ const wasGranted = this.consent === 'granted'
230
297
  this.consent = granted ? 'granted' : 'denied'
231
- if (granted) {
232
- this.flush()
233
- } else {
298
+
299
+ if (!granted) {
234
300
  this.queue = []
301
+ return
302
+ }
303
+
304
+ this.flush()
305
+
306
+ // Fires on the TRANSITION only, so a component calling grant() twice does
307
+ // not load a site's tags twice. The callback is cleared as it runs: this is
308
+ // a one-time permission, not a subscription.
309
+ if (!wasGranted && this.onGranted) {
310
+ const notify = this.onGranted
311
+ this.onGranted = null
312
+ notify()
235
313
  }
236
314
  }
237
315
 
@@ -330,35 +408,34 @@ export default class Tracker {
330
408
  })
331
409
  }
332
410
 
333
- /** @private */
411
+ /**
412
+ * Both of these are armed once, for the life of the document, and are never
413
+ * detached — so neither the interval id nor the handler references are kept.
414
+ *
415
+ * ⛔ **There is deliberately no `destroy()`.** One shipped, was called by
416
+ * nothing, and cost 334 bytes minified in a package **every site loads
417
+ * whether it tracks or not** (`@uniweb/core` is not tree-shaken — the
418
+ * singleton's constructor holds a `Tracker`, so the class can never be
419
+ * dropped). Removing it took three instance fields with it, since they
420
+ * existed only to serve it.
421
+ *
422
+ * ⭐ The precedent is the thing this class replaced: `analytics.js` was dead
423
+ * code that shipped to every site for months because nobody deleted it.
424
+ * Adding a never-called teardown method would have been the same mistake at
425
+ * smaller scale. If a lifecycle that needs teardown ever appears, it arrives
426
+ * *with* its call site — which is the order that keeps this honest.
427
+ *
428
+ * @private
429
+ */
334
430
  armFlushInterval() {
335
- this.flushIntervalId = setInterval(() => this.flush(), this.flushInterval)
431
+ setInterval(() => this.flush(), this.flushInterval)
336
432
  }
337
433
 
338
434
  /** @private */
339
435
  armUnloadHandlers() {
340
- this.onPageHide = () => this.flush(true)
341
- this.onVisibilityChange = () => {
436
+ window.addEventListener('pagehide', () => this.flush(true))
437
+ window.addEventListener('visibilitychange', () => {
342
438
  if (document.visibilityState === 'hidden') this.flush(true)
343
- }
344
- window.addEventListener('pagehide', this.onPageHide)
345
- window.addEventListener('visibilitychange', this.onVisibilityChange)
346
- }
347
-
348
- /** Stop the interval, detach listeners, and send what is left. */
349
- destroy() {
350
- if (this.flushIntervalId) {
351
- clearInterval(this.flushIntervalId)
352
- this.flushIntervalId = null
353
- }
354
- if (isBrowser) {
355
- if (this.onPageHide) window.removeEventListener('pagehide', this.onPageHide)
356
- if (this.onVisibilityChange) {
357
- window.removeEventListener('visibilitychange', this.onVisibilityChange)
358
- }
359
- }
360
- this.onPageHide = null
361
- this.onVisibilityChange = null
362
- this.flush(true)
439
+ })
363
440
  }
364
441
  }
package/src/uniweb.js CHANGED
@@ -72,7 +72,7 @@ export default class Uniweb {
72
72
  // Populated by prerender before rendering, read synchronously by Icon.
73
73
  this.iconCache = new Map()
74
74
 
75
- // Site tracking — one event stream (`kb/framework/plans/tracking.md`).
75
+ // Site tracking — one event stream.
76
76
  //
77
77
  // ⛔ Deliberately constructed DISABLED, and the runtime replaces it in L2
78
78
  // (`wire-foundation.js` → `wireTracker`). It cannot be configured here even