@akinon/pz-theme 2.0.128-rc.0 → 2.0.129-beta.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.
@@ -0,0 +1,419 @@
1
+ import { devWarn } from './dev-warn';
2
+
3
+ /**
4
+ * Neutral interaction signals for theme-composed sections and blocks
5
+ * (ZERO-5156).
6
+ *
7
+ * The sibling of `analytics.ts`, for the moments that are not commerce: a
8
+ * section scrolling into view, a slide changing, a modal opening, a promo
9
+ * being dismissed. Same doctrine — the theme reports a FACT in a neutral
10
+ * vocabulary and never knows GA4, Insider or any tag manager exists; a brand
11
+ * maps the fact to its own schema in its own repo, and a brand that listens
12
+ * to nothing gets exactly zero behaviour change.
13
+ *
14
+ * Why a second channel rather than a new action on `pz:commerce`: the one
15
+ * bridge that exists in the fleet ends in an unconditional flat dataLayer
16
+ * push, reached today only by `search`. The first UI moment named on that
17
+ * channel would start writing bare events into a live GA4 property the day
18
+ * the brand bumps this package. `pz:theme` cannot reach that listener at all,
19
+ * and `attachGa4DataLayerBridge` listens on the commerce name and never sees
20
+ * this one.
21
+ *
22
+ * This module is the ONLY place that dispatches the new channel, so the three
23
+ * rules every moment needs live here once instead of in thirty call sites:
24
+ * silence while the theme editor is attached, a latch so a repeat is a no-op,
25
+ * and a ceiling so an autoplaying carousel cannot bill a brand for a thousand
26
+ * hits an hour.
27
+ *
28
+ * It carries no `'use client'` and imports only `./dev-warn`, which imports
29
+ * nothing. That is deliberate and pinned by a test: a module reachable from a
30
+ * server render must not pull a client reference in, and the emitter has to be
31
+ * callable from plain modules (the overlay bus, a delegated DOM listener) as
32
+ * well as from components.
33
+ */
34
+
35
+ export const THEME_SIGNAL_EVENT_NAME = 'pz:theme';
36
+
37
+ /**
38
+ * The moments this build can report.
39
+ *
40
+ * A closed union rather than `string`, for three reasons: a typo fails the
41
+ * build instead of producing a silent second event name in a brand's GA4
42
+ * property; the editor's capability announce can be derived from the frozen
43
+ * array below rather than maintained beside it; and a brand bridge can switch
44
+ * exhaustively.
45
+ *
46
+ * Dot-namespaced so a name can never collide with a GA4 reserved or
47
+ * recommended event (`share`, `video_start`, `select_promotion`). What a
48
+ * moment MEANS in analytics terms is the brand's decision, taken in its
49
+ * bridge or in its container — the theme only says what happened.
50
+ */
51
+ export type KnownThemeSignalName =
52
+ | 'section.seen'
53
+ | 'slide.changed'
54
+ | 'slide.control'
55
+ | 'modal.opened'
56
+ | 'modal.closed'
57
+ | 'popup.triggered'
58
+ | 'popup.suppressed'
59
+ | 'panel.opened'
60
+ | 'drawer.opened'
61
+ | 'tab.changed'
62
+ | 'disclosure.toggled'
63
+ | 'nav.clicked'
64
+ | 'promo.rotated'
65
+ | 'promo.dismissed'
66
+ | 'promo.suppressed'
67
+ | 'coupon.applied'
68
+ | 'coupon.removed'
69
+ | 'sort.changed'
70
+ | 'filter.applied'
71
+ | 'filter.cleared'
72
+ | 'share.clicked'
73
+ | 'countdown.expired'
74
+ | 'video.played'
75
+ | 'video.paused';
76
+
77
+ export const THEME_SIGNAL_NAMES: readonly KnownThemeSignalName[] = [
78
+ 'section.seen',
79
+ 'slide.changed',
80
+ 'slide.control',
81
+ 'modal.opened',
82
+ 'modal.closed',
83
+ 'popup.triggered',
84
+ 'popup.suppressed',
85
+ 'panel.opened',
86
+ 'drawer.opened',
87
+ 'tab.changed',
88
+ 'disclosure.toggled',
89
+ 'nav.clicked',
90
+ 'promo.rotated',
91
+ 'promo.dismissed',
92
+ 'promo.suppressed',
93
+ 'coupon.applied',
94
+ 'coupon.removed',
95
+ 'sort.changed',
96
+ 'filter.applied',
97
+ 'filter.cleared',
98
+ 'share.clicked',
99
+ 'countdown.expired',
100
+ 'video.played',
101
+ 'video.paused'
102
+ ];
103
+
104
+ const KNOWN_NAMES = new Set<string>(THEME_SIGNAL_NAMES);
105
+
106
+ /** The prefix a brand's own moment must carry. */
107
+ export const CUSTOM_SIGNAL_PREFIX = 'custom.';
108
+
109
+ /**
110
+ * A brand-authored moment, namespaced so it can never collide with a name this
111
+ * package adds later and a bridge can tell the two apart at a glance.
112
+ */
113
+ export type CustomThemeSignalName = `${typeof CUSTOM_SIGNAL_PREFIX}${string}`;
114
+
115
+ export type ThemeSignalName = KnownThemeSignalName | CustomThemeSignalName;
116
+
117
+ /**
118
+ * What a fact may be.
119
+ *
120
+ * Closed to flat scalars on purpose — the one deliberate difference from
121
+ * `CommerceEventDetail`, which carries an open index signature and a product
122
+ * array because GA4 ecommerce demands one. Here there is no shape in which a
123
+ * basket line, a form state, a DOM node or a shopper's typed text can be
124
+ * attached "just for now", which is the no-PII rule expressed structurally
125
+ * rather than as a review convention. Non-scalars are dropped at emit, so a
126
+ * cast does not get one through either.
127
+ */
128
+ export type ThemeSignalValue = string | number | boolean;
129
+
130
+ /** Where the moment happened. Every field optional: omit, never fabricate. */
131
+ export interface ThemeSignalIdentity {
132
+ placeholderId?: string;
133
+ sectionId?: string;
134
+ sectionType?: string;
135
+ blockId?: string;
136
+ blockType?: string;
137
+ }
138
+
139
+ /**
140
+ * What a caller passes. `null` and `undefined` members are dropped rather than
141
+ * sent, so a caller can spread an optional value without a conditional.
142
+ */
143
+ export interface ThemeSignalFacts extends ThemeSignalIdentity {
144
+ [key: string]: ThemeSignalValue | null | undefined;
145
+ }
146
+
147
+ /** What rides on the event. `name`, never `action` — see `emitThemeSignal`. */
148
+ export interface ThemeSignalDetail extends ThemeSignalIdentity {
149
+ name: ThemeSignalName;
150
+ [key: string]: ThemeSignalValue | undefined;
151
+ }
152
+
153
+ export interface ThemeSignalOptions {
154
+ /**
155
+ * An extra dimension on the tally, composited as `name:key`, so two sections
156
+ * count separately while one section counts once. The composite shape is the
157
+ * one the page-scope emitter already uses for its own latch.
158
+ */
159
+ key?: string;
160
+ /** Shorthand for `ceiling: 1` — the latch. */
161
+ once?: boolean;
162
+ /**
163
+ * How many times this composite key may fire before the counter resets.
164
+ * `Infinity` opts out. Defaults to `DEFAULT_SIGNAL_CEILING`.
165
+ */
166
+ ceiling?: number;
167
+ }
168
+
169
+ /**
170
+ * Per-key flood guard.
171
+ *
172
+ * A six-slide hero at the default autoplay speed changes slide every three
173
+ * seconds — around 1200 `slide.changed` an hour on a tab left open, each one
174
+ * billed to whatever the brand forwards to. Twenty-four is far above any
175
+ * honest reading of a single page view and far below a runaway.
176
+ */
177
+ export const DEFAULT_SIGNAL_CEILING = 24;
178
+
179
+ /**
180
+ * Total emits allowed before the counter resets, across every key.
181
+ *
182
+ * The per-key ceiling cannot catch a composition that is chatty in aggregate —
183
+ * forty tracked sections each emitting their legitimate once. This bounds the
184
+ * page as a whole.
185
+ */
186
+ export const SIGNAL_BUDGET_PER_ROUTE = 200;
187
+
188
+ /** Longest string a fact may carry before it is truncated. */
189
+ const MAX_VALUE_LENGTH = 120;
190
+
191
+ /** Most facts one detail may carry, identity included. */
192
+ const MAX_FACT_COUNT = 24;
193
+
194
+ /**
195
+ * The tally, and the route it belongs to.
196
+ *
197
+ * Reset when `location.pathname` changes, read at emit time — no router
198
+ * import, no navigation listener, nothing to unsubscribe. This is the module
199
+ * equivalent of the per-mount refs the page-scope emitter uses: a client-side
200
+ * visit to another page reports again, a re-render of the same page does not.
201
+ */
202
+ let tally = new Map<string, number>();
203
+ let tallyPathname: string | null = null;
204
+ let spent = 0;
205
+
206
+ const currentPathname = (): string =>
207
+ typeof window === 'undefined' ? '' : (window.location?.pathname ?? '');
208
+
209
+ const rollTallyForRoute = (): void => {
210
+ const pathname = currentPathname();
211
+ if (pathname === tallyPathname) return;
212
+
213
+ tallyPathname = pathname;
214
+ tally = new Map();
215
+ spent = 0;
216
+ };
217
+
218
+ /**
219
+ * True while the theme editor is attached — edit mode AND browse mode.
220
+ *
221
+ * Reads `window.__themeEditorDesigner` inline, the flag set only after a
222
+ * trusted SET_THEME_EDITOR_COOKIE arrives through the origin-guarded bridge.
223
+ * Four modules in this package read it exactly this way rather than importing
224
+ * it, so that the module holding it does not drag a client graph behind it.
225
+ *
226
+ * THE RAW FLAG, never an `isDesigner` a caller passes in. A block receives
227
+ * `isDesigner && !isBrowseMode`, so the moment a designer switches to browse
228
+ * mode that prop goes false while the editor is still attached — which is why
229
+ * the block-level commerce emits currently report a designer's clicks as a
230
+ * shopper's. Reading the flag here means no signal added through this module
231
+ * can inherit that defect, and no call site has to remember the distinction.
232
+ *
233
+ * Exported so a caller can skip expensive preparation (attaching an observer,
234
+ * measuring) rather than build a payload that would be thrown away.
235
+ */
236
+ export const isThemeSignalMuted = (): boolean =>
237
+ typeof window !== 'undefined' &&
238
+ (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner ===
239
+ true;
240
+
241
+ const isKnownName = (name: string): boolean =>
242
+ KNOWN_NAMES.has(name) ||
243
+ (name.startsWith(CUSTOM_SIGNAL_PREFIX) &&
244
+ name.length > CUSTOM_SIGNAL_PREFIX.length);
245
+
246
+ /**
247
+ * One fact, or nothing.
248
+ *
249
+ * Objects, arrays, functions and symbols are dropped rather than serialized:
250
+ * the payload contract is flat scalars, and the failure mode of "serialize it
251
+ * anyway" is a product object or a form state reaching a third-party tag.
252
+ */
253
+ const sanitizeValue = (value: unknown): ThemeSignalValue | undefined => {
254
+ if (typeof value === 'string') {
255
+ const text = value.trim();
256
+ if (!text) return undefined;
257
+ return text.length > MAX_VALUE_LENGTH
258
+ ? text.slice(0, MAX_VALUE_LENGTH)
259
+ : text;
260
+ }
261
+
262
+ if (typeof value === 'number') {
263
+ return Number.isFinite(value) ? value : undefined;
264
+ }
265
+
266
+ if (typeof value === 'boolean') return value;
267
+
268
+ return undefined;
269
+ };
270
+
271
+ const sanitizeFacts = (
272
+ name: string,
273
+ facts: ThemeSignalFacts
274
+ ): Record<string, ThemeSignalValue> => {
275
+ const clean: Record<string, ThemeSignalValue> = Object.create(null);
276
+ let count = 0;
277
+
278
+ for (const [key, value] of Object.entries(facts)) {
279
+ // `name` is set by the emitter; a caller passing one would otherwise
280
+ // rename the moment from inside its own payload.
281
+ if (key === 'name') continue;
282
+ if (value === null || value === undefined) continue;
283
+
284
+ if (count >= MAX_FACT_COUNT) {
285
+ devWarn(
286
+ 'themeSignal',
287
+ `"${name}" carries more than ${MAX_FACT_COUNT} facts; the rest are dropped`
288
+ );
289
+ break;
290
+ }
291
+
292
+ const scalar = sanitizeValue(value);
293
+ if (scalar === undefined) {
294
+ devWarn(
295
+ 'themeSignal',
296
+ `"${name}" fact "${key}" is not a string, finite number or boolean; dropped`
297
+ );
298
+ continue;
299
+ }
300
+
301
+ clean[key] = scalar;
302
+ count += 1;
303
+ }
304
+
305
+ return clean;
306
+ };
307
+
308
+ /**
309
+ * Report one theme moment.
310
+ *
311
+ * Fire-and-forget by the same rule the commerce emitter follows: observability
312
+ * must never take a block down, so everything here is guarded and a failure is
313
+ * silent in production.
314
+ *
315
+ * The detail names the moment under `name`, not `action`. That is a deliberate
316
+ * difference from the commerce channel: a bridge accidentally pointed at the
317
+ * wrong channel reads `detail.action === undefined` and drops the frame,
318
+ * instead of pushing a nameless event into a live property.
319
+ *
320
+ * @returns whether it dispatched, so a caller can stop observing once a
321
+ * one-shot moment has been reported.
322
+ */
323
+ export const emitThemeSignal = (
324
+ name: ThemeSignalName,
325
+ facts: ThemeSignalFacts = {},
326
+ options: ThemeSignalOptions = {}
327
+ ): boolean => {
328
+ if (typeof window === 'undefined') return false;
329
+
330
+ // Before anything else: the editor canvas and browse mode report nothing.
331
+ if (isThemeSignalMuted()) return false;
332
+
333
+ try {
334
+ if (!isKnownName(name)) {
335
+ devWarn(
336
+ 'themeSignal',
337
+ `unknown signal name "${name}"; add it to THEME_SIGNAL_NAMES or prefix it with "${CUSTOM_SIGNAL_PREFIX}"`
338
+ );
339
+ return false;
340
+ }
341
+
342
+ rollTallyForRoute();
343
+
344
+ if (spent >= SIGNAL_BUDGET_PER_ROUTE) return false;
345
+
346
+ const ceiling = options.once
347
+ ? 1
348
+ : (options.ceiling ?? DEFAULT_SIGNAL_CEILING);
349
+
350
+ if (ceiling !== Infinity) {
351
+ const tallyKey = options.key ? `${name}:${options.key}` : name;
352
+ const seen = tally.get(tallyKey) ?? 0;
353
+ if (seen >= ceiling) return false;
354
+ tally.set(tallyKey, seen + 1);
355
+ }
356
+
357
+ spent += 1;
358
+
359
+ window.dispatchEvent(
360
+ new CustomEvent<ThemeSignalDetail>(THEME_SIGNAL_EVENT_NAME, {
361
+ detail: { name, ...sanitizeFacts(name, facts) }
362
+ })
363
+ );
364
+
365
+ return true;
366
+ } catch {
367
+ // Never let a reporting failure surface as a storefront error.
368
+ return false;
369
+ }
370
+ };
371
+
372
+ export interface ThemeSignalListenerOptions {
373
+ /** Only these moments reach the listener. */
374
+ names?: readonly ThemeSignalName[];
375
+ }
376
+
377
+ /**
378
+ * Listen for theme moments.
379
+ *
380
+ * A typed wrapper over `addEventListener` so a brand does not hand-roll the
381
+ * CustomEvent cast, and so the event name lives in one place. Returns a detach
382
+ * function, which is what makes `useEffect(() => subscribeToThemeSignals(fn), [])`
383
+ * the whole integration on the brand side.
384
+ *
385
+ * No default GA4 bridge ships for this channel, on purpose: `add_to_cart` has
386
+ * an industry-standard mapping, "promo dismissed" does not, and shipping one
387
+ * would be this package choosing a brand's analytics schema.
388
+ */
389
+ export const subscribeToThemeSignals = (
390
+ listener: (detail: ThemeSignalDetail) => void,
391
+ options: ThemeSignalListenerOptions = {}
392
+ ): (() => void) => {
393
+ if (typeof window === 'undefined') return () => {};
394
+
395
+ const wanted = options.names ? new Set<string>(options.names) : null;
396
+
397
+ const handler = (event: Event) => {
398
+ const detail = (event as CustomEvent<ThemeSignalDetail>).detail;
399
+ if (!detail?.name) return;
400
+ if (wanted && !wanted.has(detail.name)) return;
401
+
402
+ try {
403
+ listener(detail);
404
+ } catch {
405
+ // A brand callback that throws must not break the dispatch for others.
406
+ }
407
+ };
408
+
409
+ window.addEventListener(THEME_SIGNAL_EVENT_NAME, handler);
410
+
411
+ return () => window.removeEventListener(THEME_SIGNAL_EVENT_NAME, handler);
412
+ };
413
+
414
+ /** Test-only: forget the route tally, matching the overlay bus reset helper. */
415
+ export const __resetThemeSignalsForTests = (): void => {
416
+ tally = new Map();
417
+ tallyPathname = null;
418
+ spent = 0;
419
+ };