From 7df8a2e002097dd9e6587fdc55242d1e7d770291 Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 14 Oct 2026 15:33:15 +0200 Subject: [PATCH 1/3] elb --- .changeset/collector-elb-arguments.md | 12 + .changeset/frame-links.md | 2 +- .../web-source-browser-elb-event-copy.md | 19 ++ README.md | 2 +- apps/demos/flows/README.md | 3 +- apps/demos/flows/index.html | 10 +- apps/demos/react/README.md | 7 +- .../storybook/src/shared/controls.test.ts | 5 +- apps/demos/storybook/src/shared/controls.ts | 11 +- .../src/shared/stories/useStoryControls.ts | 5 +- apps/demos/storybook/src/site/walkerjs.ts | 10 +- .../molecules/__tests__/view-source.test.tsx | 19 +- .../molecules/view-source.stories.tsx | 9 +- apps/quickstart/src/first-event.ts | 2 +- apps/quickstart/src/ga4-ecommerce.ts | 7 +- apps/quickstart/src/mapping-test.ts | 19 +- apps/scripts/validate-docs.ts | 54 +++- apps/walkerjs/examples/index.html | 2 +- packages/cli/openapi/spec.json | 16 +- packages/cli/src/types/api.gen.d.ts | 2 + .../src/__tests__/elb-adapter.test.ts | 108 ++++++++ packages/collector/src/elb.ts | 54 ++-- packages/collector/src/index.ts | 1 + packages/destinations/demo/README.md | 2 +- .../src/__tests__/elbArguments.test.ts | 253 ++++++++++++++++++ .../browser/src/__tests__/elbLayer.test.ts | 3 +- .../browser/src/__tests__/integration.test.ts | 10 +- .../src/__tests__/observeInternals.test.ts | 2 + .../src/__tests__/registryIsolation.test.ts | 2 + .../browser/src/__tests__/shadowDom.test.ts | 2 + .../src/__tests__/stepExamples.test.ts | 14 +- .../browser/src/__tests__/translation.test.ts | 164 +++++------- .../browser/src/__tests__/trigger.test.ts | 14 +- .../src/__tests__/triggerVisible.test.ts | 3 + .../src/__tests__/visibleGeometry.test.ts | 2 + packages/web/sources/browser/src/elbLayer.ts | 6 +- .../web/sources/browser/src/examples/step.ts | 8 - .../sources/browser/src/examples/trigger.ts | 1 + packages/web/sources/browser/src/index.ts | 30 +-- .../web/sources/browser/src/translation.ts | 154 +++++------ packages/web/sources/browser/src/trigger.ts | 1 + packages/web/sources/browser/src/types/elb.ts | 45 +--- .../web/sources/browser/src/types/index.ts | 7 +- packages/web/sources/browser/src/walker.ts | 3 + skills/walkeros-debugging/SKILL.md | 2 +- skills/walkeros-understanding-events/SKILL.md | 35 ++- skills/walkeros-understanding-flow/SKILL.md | 8 +- .../walkeros-understanding-sources/SKILL.md | 10 +- .../walkeros-writing-documentation/SKILL.md | 8 +- website/docs/collector/commands.mdx | 2 +- website/docs/destinations/web/gtag/ga4.mdx | 5 +- website/docs/destinations/web/snowplow.mdx | 31 ++- website/docs/getting-started/event-model.mdx | 42 +++ .../docs/getting-started/ga4-ecommerce.mdx | 9 +- .../docs/getting-started/modes/integrated.mdx | 24 +- .../docs/getting-started/quickstart/index.mdx | 2 +- .../getting-started/quickstart/nextjs.mdx | 6 +- .../docs/getting-started/quickstart/react.mdx | 6 +- .../getting-started/quickstart/walkerjs.mdx | 2 +- website/docs/sources/index.mdx | 4 +- website/docs/sources/web/browser/commands.mdx | 41 ++- website/docs/sources/web/browser/index.mdx | 21 +- .../web/browser/tagging/html-attributes.mdx | 4 +- .../web/browser/tagging/javascript.mdx | 98 ++++--- website/docs/sources/web/dataLayer/index.mdx | 2 +- website/docs/sources/web/session/index.mdx | 2 +- website/src/pages/demos/_DemoFrame.tsx | 7 +- 67 files changed, 960 insertions(+), 516 deletions(-) create mode 100644 .changeset/collector-elb-arguments.md create mode 100644 .changeset/web-source-browser-elb-event-copy.md create mode 100644 packages/web/sources/browser/src/__tests__/elbArguments.test.ts diff --git a/.changeset/collector-elb-arguments.md b/.changeset/collector-elb-arguments.md new file mode 100644 index 000000000..3e5361949 --- /dev/null +++ b/.changeset/collector-elb-arguments.md @@ -0,0 +1,12 @@ +--- +'@walkeros/collector': patch +--- + +`elb` takes an event object, +`elb({ name, data, trigger, context, nested, custom })`, a command, +`elb('walker ', data)`, or the string form +`elb('entity action', data)`. The positional `context`, `nested` and `custom` +arguments are gone, and a second argument after an event object is no longer +merged into `data`. Such arguments are ignored and reported as an error. The +string form takes `data` only when it is a plain object; before, any non-array +object, such as an element or a date, was taken. diff --git a/.changeset/frame-links.md b/.changeset/frame-links.md index b03f8ddad..13e115a97 100644 --- a/.changeset/frame-links.md +++ b/.changeset/frame-links.md @@ -1,5 +1,5 @@ --- -'@walkeros/mcp': minor +'@walkeros/mcp': patch '@walkeros/cli': patch --- diff --git a/.changeset/web-source-browser-elb-event-copy.md b/.changeset/web-source-browser-elb-event-copy.md new file mode 100644 index 000000000..6b493f21f --- /dev/null +++ b/.changeset/web-source-browser-elb-event-copy.md @@ -0,0 +1,19 @@ +--- +'@walkeros/web-source-browser': patch +--- + +`elb` takes an event object, +`elb({ name, data, trigger, context, nested, custom })`, or a `walker` command. +The string form `elb('entity action', data)` still works and sends exactly +`{ name, data }`. Any argument after `data` or after the event object, and +`data` that is not an object (such as an element), is ignored and reported as an +error: put trigger, context, nested and custom inside the event object. The +types `BrowserArguments`, `BrowserPushData`, `BrowserPushOptions` and +`BrowserPushContext` are removed. + +The automatic page view uses the path as `data.id` unless the page tags an id; a +page event you push keeps its `data` as written. `elb` no longer writes `source` +or `globals` onto the event or `data` object you pass in. + +An event that carries its own `globals` keeps the globals tagged on the page as +well; the event's values win per key. diff --git a/README.md b/README.md index 847f7783f..2cd2421a3 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ const { elb } = await startFlow({ }, }); -await elb('page view', { title: 'Home' }); +await elb({ name: 'page view', data: { title: 'Home' } }); // -> logs: Event: page view ``` diff --git a/apps/demos/flows/README.md b/apps/demos/flows/README.md index 30319407b..62a896684 100644 --- a/apps/demos/flows/README.md +++ b/apps/demos/flows/README.md @@ -309,7 +309,8 @@ echo "walker.js available at: $SERVE_URL/walker.js" 1. **Click buttons** - See events in the visual console 2. **Open Network tab** - Events go to YOUR endpoint, not google-analytics.com 3. **Open BigQuery** - Events arrive in your database -4. **Browser console** - `elb('custom event', { data: 'here' })` +4. **Browser console** - + `elb({ name: 'custom event', data: { label: 'here' } })` --- diff --git a/apps/demos/flows/index.html b/apps/demos/flows/index.html index 9a1f7ca15..d25c3c5e3 100644 --- a/apps/demos/flows/index.html +++ b/apps/demos/flows/index.html @@ -92,16 +92,16 @@

walkerOS Flow Demo

Quick Events

- - - -
@@ -128,7 +128,7 @@

Batch Demo (hover to trigger)

Try in browser console: - elb('custom event', { any: 'data' }) + elb({ name: 'custom event', data: { any: 'data' } })

Check Network tab: Events go to walkeros-demo-xxx.run.app/collect (YOUR endpoint)
diff --git a/apps/demos/react/README.md b/apps/demos/react/README.md index 3617b3d56..45c62a866 100644 --- a/apps/demos/react/README.md +++ b/apps/demos/react/README.md @@ -76,10 +76,9 @@ Track events manually from React components: ```typescript // Track custom event -window.elb('custom event', { - category: 'interaction', - action: 'button_click', - label: 'cta', +window.elb({ + name: 'custom event', + data: { category: 'interaction', action: 'button_click', label: 'cta' }, }); // Update consent diff --git a/apps/demos/storybook/src/shared/controls.test.ts b/apps/demos/storybook/src/shared/controls.test.ts index f9ca3844f..e925525b3 100644 --- a/apps/demos/storybook/src/shared/controls.test.ts +++ b/apps/demos/storybook/src/shared/controls.test.ts @@ -1,8 +1,7 @@ -import type { SourceBrowser } from '@walkeros/web-source-browser'; -import { elbAtApplyFrom } from './controls'; +import { elbAtApplyFrom, type PageElb } from './controls'; test('looks the elb up at each call, not when made', async () => { - let running: SourceBrowser.Push | undefined = undefined; + let running: PageElb | undefined = undefined; const elb = elbAtApplyFrom(() => running); expect(await elb('walker init', document.body)).toStrictEqual({ diff --git a/apps/demos/storybook/src/shared/controls.ts b/apps/demos/storybook/src/shared/controls.ts index 6fdd03f4c..ef2ebbbdd 100644 --- a/apps/demos/storybook/src/shared/controls.ts +++ b/apps/demos/storybook/src/shared/controls.ts @@ -1,3 +1,4 @@ +import type { Elb } from '@walkeros/core'; import type { SourceBrowser } from '@walkeros/web-source-browser'; import type { DemoConsent } from './consent'; import type { Language } from './language'; @@ -22,6 +23,12 @@ export interface DemoPageProps { controls: DemoControls; } +/** + * A page global elb as the page holds it: a function the page calls with + * whatever arguments it has. The browser source reports the ones it ignores. + */ +export type PageElb = (...args: unknown[]) => Promise; + /** Why a command was not sent: no walkerOS runs on the page (yet). */ export const notRunning = 'walkerOS is not running.'; @@ -32,9 +39,9 @@ export const notRunning = 'walkerOS is not running.'; * `{ ok: false }` with the reason, which ViewSource shows. */ export function elbAtApplyFrom( - getElb: () => SourceBrowser.Push | undefined, + getElb: () => PageElb | undefined, ): SourceBrowser.Push { - return (...args: Parameters) => { + return (...args: unknown[]) => { const elb = getElb(); return elb ? elb(...args) diff --git a/apps/demos/storybook/src/shared/stories/useStoryControls.ts b/apps/demos/storybook/src/shared/stories/useStoryControls.ts index a410c7d04..37dc5eb3b 100644 --- a/apps/demos/storybook/src/shared/stories/useStoryControls.ts +++ b/apps/demos/storybook/src/shared/stories/useStoryControls.ts @@ -1,7 +1,6 @@ import { useState } from 'react'; -import type { SourceBrowser } from '@walkeros/web-source-browser'; import { useDemoConsent } from '../consent'; -import { elbAtApplyFrom, type DemoControls } from '../controls'; +import { elbAtApplyFrom, type DemoControls, type PageElb } from '../controls'; import type { Language } from '../language'; import type { PersonaKey } from '../personas'; @@ -10,7 +9,7 @@ import type { PersonaKey } from '../personas'; // when it failed to). declare global { interface Window { - __storybookElb?: SourceBrowser.Push; + __storybookElb?: PageElb; } } diff --git a/apps/demos/storybook/src/site/walkerjs.ts b/apps/demos/storybook/src/site/walkerjs.ts index 16e94f1e3..ad0b1638e 100644 --- a/apps/demos/storybook/src/site/walkerjs.ts +++ b/apps/demos/storybook/src/site/walkerjs.ts @@ -1,4 +1,4 @@ -import type { SourceBrowser } from '@walkeros/web-source-browser'; +import type { PageElb } from '../shared/controls'; /** * The managed walker.js of the app flow "Tagging demo", the one the static @@ -11,7 +11,7 @@ export const WALKER_JS_URL = // Narrowed by `typeof`: on this page only the queue below or walker.js's // push sits there. The global's own type (`Elb.Fn`) lacks the browser-only // commands ViewSource sends, such as `walker init`. -const isPush = (value: unknown): value is SourceBrowser.Push => +const isPush = (value: unknown): value is PageElb => typeof value === 'function'; /** @@ -23,9 +23,7 @@ const isPush = (value: unknown): value is SourceBrowser.Push => export function installElbQueue(win: Window): void { const existing: unknown = win.elb; if (isPush(existing)) return; - const queue: SourceBrowser.Push = ( - ...args: Parameters - ) => { + const queue: PageElb = (...args) => { (win.elbLayer = win.elbLayer || []).push(args); return Promise.resolve({ ok: true }); }; @@ -33,7 +31,7 @@ export function installElbQueue(win: Window): void { } /** The page's elb right now: the queue until walker.js runs, then its push. */ -export function currentElb(win: Window): SourceBrowser.Push | undefined { +export function currentElb(win: Window): PageElb | undefined { const elb: unknown = win.elb; return isPush(elb) ? elb : undefined; } diff --git a/apps/explorer/src/components/molecules/__tests__/view-source.test.tsx b/apps/explorer/src/components/molecules/__tests__/view-source.test.tsx index 0adf0497a..c42fbd2ce 100644 --- a/apps/explorer/src/components/molecules/__tests__/view-source.test.tsx +++ b/apps/explorer/src/components/molecules/__tests__/view-source.test.tsx @@ -47,10 +47,9 @@ const edit = `
`; function elbResolving(result: Elb.PushResult) { - return jest.fn< - ReturnType, - Parameters - >(() => Promise.resolve(result)); + return jest.fn, unknown[]>(() => + Promise.resolve(result), + ); } type Queries = ReturnType; @@ -300,17 +299,13 @@ describe('ViewSource', () => { ['walker init fails', elbResolving({ ok: false, error: 'not running' })], [ 'walker init rejects', - jest.fn< - ReturnType, - Parameters - >(() => Promise.reject(new Error('gone'))), + jest.fn, unknown[]>(() => + Promise.reject(new Error('gone')), + ), ], [ 'elb throws', - jest.fn< - ReturnType, - Parameters - >(() => { + jest.fn, unknown[]>(() => { throw new Error('broken'); }), ], diff --git a/apps/explorer/src/components/molecules/view-source.stories.tsx b/apps/explorer/src/components/molecules/view-source.stories.tsx index 1f642328b..9677d7efd 100644 --- a/apps/explorer/src/components/molecules/view-source.stories.tsx +++ b/apps/explorer/src/components/molecules/view-source.stories.tsx @@ -24,13 +24,14 @@ type Story = StoryObj; // The Actions panel stands in for the page's walkerOS: it logs the call and // accepts it. const logInit = action('elb'); +const actionsElb: SourceBrowser.Push = async (...args: unknown[]) => { + logInit(...args); + return { ok: true }; +}; export const TaggedSample: Story = { args: { - elb: async (...args: Parameters) => { - logInit(...args); - return { ok: true }; - }, + elb: actionsElb, children: (
{ }, }); - await elb('page view', { title: 'Home' }); + await elb({ name: 'page view', data: { title: 'Home' } }); return seen; } diff --git a/apps/quickstart/src/ga4-ecommerce.ts b/apps/quickstart/src/ga4-ecommerce.ts index 3f84cb8d8..4cf523c81 100644 --- a/apps/quickstart/src/ga4-ecommerce.ts +++ b/apps/quickstart/src/ga4-ecommerce.ts @@ -55,10 +55,9 @@ export async function ga4AddToCart(): Promise { }, }); - await elb('product add', { - id: 'ers', - name: 'Everyday Ruck Snack', - price: 420, + await elb({ + name: 'product add', + data: { id: 'ers', name: 'Everyday Ruck Snack', price: 420 }, }); // The destination's init emits gtag('js', ...) and gtag('config', ...) first, diff --git a/apps/quickstart/src/mapping-test.ts b/apps/quickstart/src/mapping-test.ts index 101edb0c4..7270f273d 100644 --- a/apps/quickstart/src/mapping-test.ts +++ b/apps/quickstart/src/mapping-test.ts @@ -106,26 +106,19 @@ export async function testMapping(): Promise { }); // Event without mapping - await elb('page view', { title: 'Home Page' }); + await elb({ name: 'page view', data: { title: 'Home Page' } }); // Product view mapped to view_item - await elb('product view', { - id: 'P123', - name: 'Premium Laptop', - price: 999.99, + await elb({ + name: 'product view', + data: { id: 'P123', name: 'Premium Laptop', price: 999.99 }, }); // Product add mapped to add_to_cart - await elb('product add', { - id: 'P123', - quantity: 2, - }); + await elb({ name: 'product add', data: { id: 'P123', quantity: 2 } }); // Order complete with calculated tax - await elb('order complete', { - id: 'ORDER-789', - total: 1000, - }); + await elb({ name: 'order complete', data: { id: 'ORDER-789', total: 1000 } }); const { collector } = await startFlow({ sources: { diff --git a/apps/scripts/validate-docs.ts b/apps/scripts/validate-docs.ts index 4ef17b08a..01bf88a34 100644 --- a/apps/scripts/validate-docs.ts +++ b/apps/scripts/validate-docs.ts @@ -530,6 +530,36 @@ export function findCloudCalloutIssues(content: string): BoundaryFinding[] { : []; } +// Events are taught in one form: `elb` takes a partial event, +// `elb({ name: 'entity action', data })`. A quoted "entity action" string as the +// first argument is the string form; `walker` commands keep their string name. +// The match spans line breaks so a call split over lines is found too. +export function findElbStringEventIssues(content: string): BoundaryFinding[] { + const findings: BoundaryFinding[] = []; + const re = /\belb\(\s*(['"`])(?!walker )([^'"`\n]+ [^'"`\n]*)\1/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + findings.push({ + line: content.slice(0, m.index).split('\n').length, + message: `elb event "${m[2]}" uses the string form: pass a partial event, elb({ name: '${m[2]}', data })`, + }); + } + return findings; +} + +function checkElbEventForm(file: string, content: string): void { + // Migration guides may quote the form a version transition replaced. + if (/website\/docs\/migrat(e|ing)\//.test(file)) return; + for (const f of findElbStringEventIssues(content)) { + issues.push({ + file: relative(ROOT, file), + line: f.line, + severity: 'error', + message: f.message, + }); + } +} + function checkBoundaryRegister(): void { const scopeGlobs = [ 'website/docs/getting-started/**/*.mdx', @@ -599,8 +629,8 @@ function checkFirstEventSnippet(): void { const normalize = (s: string): string => s.replace(/["']/g, "'").replace(/\s+/g, ' ').trim(); - // Extract the event name from `elb('page view', ...)` in the source. - const eventMatch = source.match(/elb\(\s*['"]([^'"]+)['"]/); + // Extract the event name from `elb({ name: 'page view', ... })` in the source. + const eventMatch = source.match(/elb\(\s*\{\s*name:\s*['"]([^'"]+)['"]/); // Extract the console.log message literal from `console.log('Event:', ...)`. const logMatch = source.match(/console\.log\(\s*['"]([^'"]+)['"]/); @@ -621,7 +651,7 @@ function checkFirstEventSnippet(): void { const requiredTokens = [ normalize("type: 'console'"), normalize(`console.log('${logMessage}', event.name)`), - normalize(`elb('${eventName}'`), + normalize(`elb({ name: '${eventName}'`), ]; const docFiles = [ @@ -780,10 +810,10 @@ function checkGa4MappingSnippet(): void { const eventName = nameMatch[1]; // e.g. "add_to_cart" // Extract the product id from the source's push input - // (`elb('product add', { id: 'ers', ... })`) so the pinned push snippet stays - // derived from the verified source rather than hardcoded. + // (`elb({ name: 'product add', data: { id: 'ers', ... } })`) so the pinned + // push snippet stays derived from the verified source rather than hardcoded. const idMatch = source.match( - /elb\(\s*['"]product add['"]\s*,\s*\{\s*id:\s*['"]([^'"]+)['"]/, + /elb\(\s*\{\s*name:\s*['"]product add['"]\s*,\s*data:\s*\{\s*id:\s*['"]([^'"]+)['"]/, ); if (!idMatch) { issues.push({ @@ -804,7 +834,7 @@ function checkGa4MappingSnippet(): void { normalize("item_id: 'data.id'"), normalize("item_name: 'data.name'"), normalize("value: 'data.price'"), - normalize(`elb('product add', { id: '${productId}'`), + normalize(`elb({ name: 'product add', data: { id: '${productId}'`), ]; // Defensive: the source itself must carry every required token, otherwise the @@ -1135,7 +1165,17 @@ async function main() { checkLegacyPatterns(file, content); checkDomainRefs(file, content); checkReadmeStructure(file, content); + checkElbEventForm(file, content); } + // The elb event form also binds the root and app READMEs, which the + // package-README structure checks above do not cover. + const readmes = await glob(['README.md', 'apps/**/README.md'], { + cwd: ROOT, + ignore: ['**/node_modules/**', '**/dist/**'], + absolute: true, + }); + for (const file of readmes) + checkElbEventForm(file, readFileSync(file, 'utf-8')); checkBundledSnippets(); checkFirstEventSnippet(); diff --git a/apps/walkerjs/examples/index.html b/apps/walkerjs/examples/index.html index a0c9a149b..0e336edfa 100644 --- a/apps/walkerjs/examples/index.html +++ b/apps/walkerjs/examples/index.html @@ -32,7 +32,7 @@

Cotton Tee

- +

dataLayer pushes


diff --git a/packages/cli/openapi/spec.json b/packages/cli/openapi/spec.json
index 0978272d6..c10433d39 100644
--- a/packages/cli/openapi/spec.json
+++ b/packages/cli/openapi/spec.json
@@ -2,7 +2,7 @@
   "openapi": "3.1.0",
   "info": {
     "title": "walkerOS Tag Manager API",
-    "version": "4.7.0+88043196",
+    "version": "4.7.0+9c4b69f0",
     "contact": {
       "name": "elbwalker",
       "url": "https://www.elbwalker.com"
@@ -6428,6 +6428,12 @@
           "variantSetId": {
             "type": ["string", "null"]
           },
+          "labels": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
           "flowId": {
             "type": "string",
             "pattern": "^flow_[a-zA-Z0-9_-]+$"
@@ -6484,6 +6490,7 @@
           "origin",
           "links",
           "variantSetId",
+          "labels",
           "flowId",
           "screenshot",
           "markupAssetId",
@@ -6570,6 +6577,12 @@
           "variantSetId": {
             "type": ["string", "null"]
           },
+          "labels": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
           "flowId": {
             "type": "string",
             "pattern": "^flow_[a-zA-Z0-9_-]+$"
@@ -6625,6 +6638,7 @@
           "origin",
           "links",
           "variantSetId",
+          "labels",
           "flowId",
           "screenshot",
           "markupAssetId",
diff --git a/packages/cli/src/types/api.gen.d.ts b/packages/cli/src/types/api.gen.d.ts
index a9e4b4112..a7806acf7 100644
--- a/packages/cli/src/types/api.gen.d.ts
+++ b/packages/cli/src/types/api.gen.d.ts
@@ -4120,6 +4120,7 @@ export interface components {
       origin: 'drawn' | 'imported' | 'observed';
       links: components['schemas']['FrameLink'][];
       variantSetId: string | null;
+      labels: string[];
       flowId: string;
       screenshot: components['schemas']['FrameScreenshot'] | null;
       markupAssetId: string | null;
@@ -4159,6 +4160,7 @@ export interface components {
       origin: 'drawn' | 'imported' | 'observed';
       links: components['schemas']['FrameLink'][];
       variantSetId: string | null;
+      labels: string[];
       flowId: string;
       screenshot: components['schemas']['FrameScreenshot'] | null;
       markupAssetId: string | null;
diff --git a/packages/collector/src/__tests__/elb-adapter.test.ts b/packages/collector/src/__tests__/elb-adapter.test.ts
index 8d1374e3d..f0b6bfb2f 100644
--- a/packages/collector/src/__tests__/elb-adapter.test.ts
+++ b/packages/collector/src/__tests__/elb-adapter.test.ts
@@ -1,3 +1,4 @@
+import type { Destination, Logger, WalkerOS } from '@walkeros/core';
 import { createElb } from '../elb';
 import { startFlow } from '../flow';
 import { createPushResult } from '../destination';
@@ -26,3 +27,110 @@ describe('createElb adapter', () => {
     expect(pushed).toEqual(event);
   });
 });
+
+// A real collector with a spy destination and a log recorder: what arrives is
+// what the destination gets, what the page sees is what the logger reports.
+async function setup() {
+  const events: WalkerOS.Event[] = [];
+  const errors: string[] = [];
+  const spy: Destination.Instance = {
+    type: 'spy',
+    config: {},
+    push: (event) => {
+      events.push(event);
+    },
+  };
+  const handler: Logger.Handler = (level, message) => {
+    if (level === 0) errors.push(message);
+  };
+  const { collector, elb } = await startFlow({
+    destinations: { spy: { code: spy } },
+    logger: { handler },
+  });
+  return { collector, elb, events, errors };
+}
+
+// Untyped page code reaches elb with any arguments; the typed signature has
+// none of these forms, so the calls go through a plain function value.
+const untyped = (fn: unknown): ((...args: unknown[]) => Promise) => {
+  if (typeof fn !== 'function') throw new Error('not a function');
+  return (...args) => Promise.resolve(Reflect.apply(fn, undefined, args));
+};
+
+describe('elb arguments beyond data', () => {
+  it.each<[string, unknown[]]>([
+    [
+      'positional context, nested and custom',
+      [
+        'product view',
+        { id: 'P1' },
+        { position: ['top', 0] },
+        [{ entity: 'size', data: {} }],
+        { flag: true },
+      ],
+    ],
+    [
+      'a second argument after an event object',
+      [{ name: 'product view', data: { id: 'P1' } }, { extra: 'x' }],
+    ],
+    ['data that is not an object', ['product view', 'P1']],
+  ])('ignores %s and reports it', async (_label, args) => {
+    const { elb, events, errors } = await setup();
+
+    await untyped(elb)(...args);
+
+    expect(events).toHaveLength(1);
+    expect(events[0]).toMatchObject({
+      name: 'product view',
+      context: {},
+      nested: [],
+      custom: {},
+    });
+    const expectedData = args[0] === 'product view' && args[1] === 'P1';
+    expect(events[0].data).toEqual(expectedData ? {} : { id: 'P1' });
+    expect(errors).toHaveLength(1);
+    expect(errors[0]).toMatch(/elb/);
+    expect(errors[0]).toMatch(/trigger, context, nested, custom/);
+  });
+
+  it('reports once per event name', async () => {
+    const { elb, events, errors } = await setup();
+
+    await untyped(elb)('product view', { id: 'P1' }, { position: ['a', 0] });
+    await untyped(elb)('product view', { id: 'P2' }, 'click');
+    expect(errors).toHaveLength(1);
+
+    await untyped(elb)('order complete', { id: 'O1' }, 'click');
+
+    expect(events.map((event) => event.data.id)).toEqual(['P1', 'P2', 'O1']);
+    expect(errors).toHaveLength(2);
+  });
+
+  it('runs a walker command with an extra argument and reports it', async () => {
+    const { collector, elb, errors } = await setup();
+
+    await untyped(elb)('walker user', { id: 'u1' }, 'extra');
+
+    expect(collector.user).toEqual({ id: 'u1' });
+    expect(errors).toHaveLength(1);
+  });
+
+  it('reports nothing for the forms elb takes', async () => {
+    const { elb, events, errors } = await setup();
+
+    await elb({ name: 'product view', data: { id: 'P1' } });
+    await elb('product add', { id: 'P2' });
+    await elb('page view');
+    await elb('walker user', { id: 'u1' });
+    // A trailing undefined carries nothing, so it is not an extra argument.
+    await untyped(elb)('order complete', { id: 'O1' }, undefined);
+
+    expect(events.map((event) => event.name)).toEqual([
+      'product view',
+      'product add',
+      'page view',
+      'order complete',
+    ]);
+    expect(errors).toEqual([]);
+  });
+});
diff --git a/packages/collector/src/elb.ts b/packages/collector/src/elb.ts
index a923bdb79..5f5773af0 100644
--- a/packages/collector/src/elb.ts
+++ b/packages/collector/src/elb.ts
@@ -1,27 +1,47 @@
 import type { Collector, WalkerOS, Elb } from '@walkeros/core';
+import { isObject } from '@walkeros/core';
 import { createPushResult } from './destination';
 
+/**
+ * The ERROR an `elb` call reports for an argument its call form does not take.
+ * Shared with the browser source, which reports its own calls the same way.
+ */
+export const ELB_IGNORED_ARGUMENTS =
+  'elb ignored an argument it does not take: pass data as an object and put trigger, context, nested, custom inside the event object, elb({ name, data, trigger, context, nested, custom })';
+
 /**
  * Creates the default ELB adapter.
- * Routes between collector.push and collector.command based on input.
- * Provides backward-compatible flexible argument interface.
+ * Routes between collector.push and collector.command based on input:
+ * `elb(partialEvent)`, `elb('walker ', data)` and the string form
+ * `elb('entity action', data)`, which is exactly `{ name, data }`.
+ *
+ * An argument none of these forms takes is ignored; the call still runs and
+ * is reported at ERROR, once per event or command name.
  *
  * @param collector - The walkerOS collector instance
  * @returns ELB adapter function
  */
 export function createElb(collector: Collector.Instance): Elb.Fn {
+  // Per name, so one event's report never hides another's.
+  const reported = new Set();
+  const reportIgnored = (name: string) => {
+    if (reported.has(name)) return;
+    reported.add(name);
+    collector.logger.error(ELB_IGNORED_ARGUMENTS, { event: name });
+  };
+  const hasExtra = (rest: unknown[]) => rest.some((arg) => arg !== undefined);
+
   return async (
     eventOrCommand?: unknown,
     data?: unknown,
-    context?: unknown,
-    nested?: WalkerOS.Entities,
-    custom?: WalkerOS.Properties,
+    ...rest: unknown[]
   ): Promise => {
     // Detect walker commands
     if (
       typeof eventOrCommand === 'string' &&
       eventOrCommand.startsWith('walker ')
     ) {
+      if (hasExtra(rest)) reportIgnored(eventOrCommand);
       const command = eventOrCommand.replace('walker ', '');
       return collector.command(command, data);
     }
@@ -32,9 +52,8 @@ export function createElb(collector: Collector.Instance): Elb.Fn {
     if (typeof eventOrCommand === 'string') {
       // Convert string to object: elb('page view', { title: 'Home' })
       event = { name: eventOrCommand };
-      if (data && typeof data === 'object' && !Array.isArray(data)) {
-        event.data = data as WalkerOS.Properties;
-      }
+      if (isObject(data)) event.data = data as WalkerOS.Properties;
+      else if (data !== undefined) reportIgnored(eventOrCommand);
     } else if (
       eventOrCommand &&
       typeof eventOrCommand === 'object' &&
@@ -44,28 +63,13 @@ export function createElb(collector: Collector.Instance): Elb.Fn {
       // Use object directly: elb({ name: 'page view', data: {...} })
       // Shallow-clone so adapter writes never leak onto the caller's object
       event = { ...(eventOrCommand as WalkerOS.DeepPartialEvent) };
-      // Merge additional data if provided
-      if (data && typeof data === 'object' && !Array.isArray(data)) {
-        event.data = {
-          ...(event.data || {}),
-          ...(data as WalkerOS.Properties),
-        };
-      }
+      if (data !== undefined) reportIgnored(String(event.name));
     } else {
       // Invalid input
       return createPushResult({ ok: false });
     }
 
-    // Add optional properties if provided
-    if (context && typeof context === 'object') {
-      event.context = context as WalkerOS.OrderedProperties;
-    }
-    if (nested && Array.isArray(nested)) {
-      event.nested = nested;
-    }
-    if (custom && typeof custom === 'object') {
-      event.custom = custom as WalkerOS.Properties;
-    }
+    if (hasExtra(rest)) reportIgnored(String(event.name));
 
     // Call collector.push with event object
     return collector.push(event);
diff --git a/packages/collector/src/index.ts b/packages/collector/src/index.ts
index f446b2cce..10c23f7d5 100644
--- a/packages/collector/src/index.ts
+++ b/packages/collector/src/index.ts
@@ -54,4 +54,5 @@ export {
   transformerInit,
 } from './transformer';
 export { wrapEnv } from './wrapEnv';
+export { ELB_IGNORED_ARGUMENTS } from './elb';
 export { getCacheStore } from './cache';
diff --git a/packages/destinations/demo/README.md b/packages/destinations/demo/README.md
index 8bdac39a7..4d48d1124 100644
--- a/packages/destinations/demo/README.md
+++ b/packages/destinations/demo/README.md
@@ -31,7 +31,7 @@ const { elb } = await startFlow({
   },
 });
 
-await elb('page view', { title: 'Home' });
+await elb({ name: 'page view', data: { title: 'Home' } });
 ```
 
 ## Documentation
diff --git a/packages/web/sources/browser/src/__tests__/elbArguments.test.ts b/packages/web/sources/browser/src/__tests__/elbArguments.test.ts
new file mode 100644
index 000000000..d9247d56b
--- /dev/null
+++ b/packages/web/sources/browser/src/__tests__/elbArguments.test.ts
@@ -0,0 +1,253 @@
+import type { Collector, Destination, Logger, WalkerOS } from '@walkeros/core';
+import { startFlow } from '@walkeros/collector';
+import { sourceBrowser } from '../index';
+import type { BrowserPush, InitSettings } from '../types';
+import { flushChain } from './test-utils';
+
+// What `elb` takes, end to end: a real collector, the browser source and a spy
+// destination. What arrives is what the destination gets; what the page sees
+// of an argument the source ignores is the ERROR the logger reports.
+
+let current: Collector.Instance | undefined;
+let events: WalkerOS.Event[] = [];
+let errors: string[] = [];
+
+async function start(settings: InitSettings = {}) {
+  const spy: Destination.Instance = {
+    type: 'spy',
+    config: {},
+    push: (event) => {
+      events.push(event);
+    },
+  };
+  const handler: Logger.Handler = (level, message) => {
+    if (level === 0) errors.push(message);
+  };
+  const flow = await startFlow({
+    sources: {
+      browser: {
+        code: sourceBrowser,
+        config: { settings: { pageview: false, ...settings } },
+      },
+    },
+    destinations: { spy: { code: spy } },
+    logger: { handler },
+  });
+  current = flow.collector;
+  await flushChain();
+  return flow;
+}
+
+// Page code calls `window.elb` untyped, with whatever arguments it has.
+const pageElb = (...args: unknown[]): Promise => {
+  const fn: unknown = Reflect.get(window, 'elb');
+  if (typeof fn !== 'function') throw new Error('window.elb not installed');
+  return Promise.resolve(Reflect.apply(fn, window, args));
+};
+
+const named = (name: string) => events.filter((event) => event.name === name);
+
+beforeEach(() => {
+  events = [];
+  errors = [];
+  document.body.innerHTML = '';
+  document.body.removeAttribute('data-elb-page');
+  Reflect.deleteProperty(window, 'elbLayer');
+  Reflect.deleteProperty(window, 'elb');
+  window.history.replaceState({}, '', '/docs');
+});
+
+afterEach(async () => {
+  await current?.command('shutdown');
+  current = undefined;
+});
+
+describe('the string form', () => {
+  it('is exactly name and data, with the browser source and page globals', async () => {
+    document.body.innerHTML = '
'; + const { elb } = await start(); + const data = { id: 'P1' }; + + await elb('product view', data); + + expect(named('product view')).toEqual([ + expect.objectContaining({ + name: 'product view', + data: { id: 'P1' }, + context: {}, + globals: { pagegroup: 'shop' }, + nested: [], + custom: {}, + trigger: '', + source: expect.objectContaining({ + type: 'browser', + url: 'https://example.com/docs', + }), + }), + ]); + expect(data).toEqual({ id: 'P1' }); + expect(errors).toEqual([]); + }); + + it('keeps the data of a page event as written', async () => { + const { elb } = await start(); + + await elb('page view', { title: 'Home' }); + await elb({ name: 'page view', data: { title: 'Home' } }); + + expect(named('page view').map((event) => event.data)).toEqual([ + { title: 'Home' }, + { title: 'Home' }, + ]); + }); +}); + +describe('arguments the source ignores', () => { + const tagged = () => { + const el = document.createElement('div'); + el.setAttribute('data-elb', 'product'); + el.setAttribute('data-elb-product', 'id:P1'); + el.setAttribute('data-elbcontext', 'shelf:top'); + return el; + }; + + it.each<[string, () => unknown[], WalkerOS.Properties]>([ + [ + 'positional trigger, context, nested and custom', + () => [ + 'product view', + { id: 'P1' }, + 'click', + { position: ['top', 0] }, + [{ entity: 'size', data: {} }], + { flag: true }, + ], + { id: 'P1' }, + ], + ['an element as data', () => ['product view', tagged()], {}], + ['a string as data', () => ['product view', 'P1'], {}], + [ + 'a second argument after an event object', + () => [{ name: 'product view', data: { id: 'P1' } }, { id: 'P2' }], + { id: 'P1' }, + ], + ])('ignores %s and reports it', async (_label, args, data) => { + await start(); + + await pageElb(...args()); + await flushChain(); + + expect(named('product view')).toEqual([ + expect.objectContaining({ + data, + context: {}, + nested: [], + custom: {}, + }), + ]); + expect(named('product view')[0].trigger).not.toBe('click'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/trigger, context, nested, custom/); + }); + + it('reports once per event name', async () => { + await start(); + + await pageElb('product view', { id: 'P1' }, 'click'); + await pageElb('product view', { id: 'P2' }, 'click', { cart: ['a', 0] }); + await flushChain(); + expect(errors).toHaveLength(1); + + await pageElb('order complete', { id: 'O1' }, 'click'); + await flushChain(); + + expect(events.map((event) => event.name)).toEqual([ + 'product view', + 'product view', + 'order complete', + ]); + expect(errors).toHaveLength(2); + }); + + it('takes a trailing undefined as no argument', async () => { + await start(); + + await pageElb('product view', { id: 'P1' }, undefined); + await pageElb({ name: 'product add' }, undefined, undefined); + await flushChain(); + + expect(events.map((event) => event.name)).toEqual([ + 'product view', + 'product add', + ]); + expect(errors).toEqual([]); + }); + + it('runs a walker command with an extra argument and reports it', async () => { + const { collector } = await start(); + + await pageElb('walker user', { id: 'u1' }, 'extra'); + await flushChain(); + + expect(collector.user).toEqual({ id: 'u1' }); + expect(errors).toHaveLength(1); + }); + + it('reports nothing for walker init with elements', async () => { + await start(); + const el = document.createElement('div'); + el.setAttribute('data-elb', 'promo'); + el.setAttribute('data-elbaction', 'load:view'); + document.body.appendChild(el); + + await pageElb('walker init', [el]); + await flushChain(); + + expect(named('promo view')).toHaveLength(1); + expect(errors).toEqual([]); + }); + + it('reports extras on elbLayer entries, stub arguments included', async () => { + // The page snippet queues its `arguments` object itself. + const stub = function (..._args: unknown[]) { + return arguments; + }; + window.elbLayer = [ + ['product view', { id: 'P1' }, 'click', { position: ['top', 0] }], + stub('product add', { id: 'P2' }, 'click'), + ]; + + await start(); + await flushChain(); + + expect( + events.map((event) => [event.name, event.data, event.trigger]), + ).toEqual([ + ['product view', { id: 'P1' }, ''], + ['product add', { id: 'P2' }, ''], + ]); + // One report per event name. + expect(errors).toHaveLength(2); + }); +}); + +describe('the automatic page view', () => { + it('carries the path as data.id', async () => { + await start({ pageview: true }); + + expect(named('page view')).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ id: '/docs' }), + trigger: 'load', + }), + ]); + }); + + it('keeps a tagged page id', async () => { + document.body.setAttribute('data-elb-page', 'id:home'); + + await start({ pageview: true }); + + expect(named('page view')[0].data.id).toBe('home'); + }); +}); diff --git a/packages/web/sources/browser/src/__tests__/elbLayer.test.ts b/packages/web/sources/browser/src/__tests__/elbLayer.test.ts index 3a6760b9f..1e84c18fe 100644 --- a/packages/web/sources/browser/src/__tests__/elbLayer.test.ts +++ b/packages/web/sources/browser/src/__tests__/elbLayer.test.ts @@ -1,5 +1,5 @@ import { createPushResult } from '@walkeros/collector'; -import { isObject, isString } from '@walkeros/core'; +import { isObject, isString, createMockLogger } from '@walkeros/core'; import type { Elb } from '@walkeros/core'; import { createElbLayer } from '../elbLayer'; import { createRegistry } from '../trigger'; @@ -44,6 +44,7 @@ const makeHarness = () => { push: elb, settings: settings(), registry: createRegistry(), + logger: createMockLogger(), initScope, }; diff --git a/packages/web/sources/browser/src/__tests__/integration.test.ts b/packages/web/sources/browser/src/__tests__/integration.test.ts index 350f8379c..15dcd5b10 100644 --- a/packages/web/sources/browser/src/__tests__/integration.test.ts +++ b/packages/web/sources/browser/src/__tests__/integration.test.ts @@ -54,7 +54,11 @@ describe('Browser Source Integration Tests', () => { // Manually trigger event to test source information addition if (elb) { - await elb('product view', { id: 123, name: 'Test Product' }, 'load'); + await elb({ + name: 'product view', + data: { id: 123, name: 'Test Product' }, + trigger: 'load', + }); } // Should have processed the event with source information @@ -137,8 +141,8 @@ describe('Browser Source Integration Tests', () => { // Pre-populate elbLayer with commands window.elbLayer = [ ['walker run', { consent: { marketing: true } }], - ['page view', { title: 'Home' }, 'load', { url: '/' }], - ['product click', { id: '123' }, 'click', { position: 1 }], + [{ name: 'page view', data: { title: 'Home' }, trigger: 'load' }], + [{ name: 'product click', data: { id: '123' }, trigger: 'click' }], ]; // Initialize source - should process existing commands. diff --git a/packages/web/sources/browser/src/__tests__/observeInternals.test.ts b/packages/web/sources/browser/src/__tests__/observeInternals.test.ts index 5727bf020..7cec82852 100644 --- a/packages/web/sources/browser/src/__tests__/observeInternals.test.ts +++ b/packages/web/sources/browser/src/__tests__/observeInternals.test.ts @@ -1,4 +1,5 @@ import type { Elb } from '@walkeros/core'; +import { createMockLogger } from '@walkeros/core'; import type { Context, ElementRegistration, @@ -106,6 +107,7 @@ describe('observe internals', () => { push: mockElb, settings: createTestSettings(), registry, + logger: createMockLogger(), }; }); diff --git a/packages/web/sources/browser/src/__tests__/registryIsolation.test.ts b/packages/web/sources/browser/src/__tests__/registryIsolation.test.ts index a68577f8b..754458a6d 100644 --- a/packages/web/sources/browser/src/__tests__/registryIsolation.test.ts +++ b/packages/web/sources/browser/src/__tests__/registryIsolation.test.ts @@ -1,4 +1,5 @@ import type { Elb } from '@walkeros/core'; +import { createMockLogger } from '@walkeros/core'; import type { Context, InitScope } from '../types'; import { createRegistry, @@ -52,6 +53,7 @@ const makeContext = (scope: InitScope, elb: Elb.Fn): Context => { elbLayer: false, }, registry: createRegistry(), + logger: createMockLogger(), }; built.push(context); return context; diff --git a/packages/web/sources/browser/src/__tests__/shadowDom.test.ts b/packages/web/sources/browser/src/__tests__/shadowDom.test.ts index 563160525..41dc63700 100644 --- a/packages/web/sources/browser/src/__tests__/shadowDom.test.ts +++ b/packages/web/sources/browser/src/__tests__/shadowDom.test.ts @@ -1,4 +1,5 @@ import type { Elb } from '@walkeros/core'; +import { createMockLogger } from '@walkeros/core'; import type { Settings, Context, Registry } from '../types'; import { getAllEvents, getEvents, getGlobals } from '../walker'; import { @@ -65,6 +66,7 @@ describe('Shadow DOM', () => { push: mockElb, settings: createTestSettings(prefix), registry, + logger: createMockLogger(), }); beforeEach(() => { diff --git a/packages/web/sources/browser/src/__tests__/stepExamples.test.ts b/packages/web/sources/browser/src/__tests__/stepExamples.test.ts index d8b588fb1..7c8970110 100644 --- a/packages/web/sources/browser/src/__tests__/stepExamples.test.ts +++ b/packages/web/sources/browser/src/__tests__/stepExamples.test.ts @@ -1,5 +1,5 @@ import type { Elb, Flow } from '@walkeros/core'; -import { isObject } from '@walkeros/core'; +import { isObject, createMockLogger } from '@walkeros/core'; import { sourceBrowser } from '../index'; import { getConfig } from '../config'; import { createRegistry, handleTrigger } from '../trigger'; @@ -74,6 +74,7 @@ describe('Step Examples', () => { push: mockElb, settings, registry: createRegistry(), + logger: createMockLogger(), }; const type = triggerInfo?.type; @@ -88,13 +89,12 @@ describe('Step Examples', () => { settings.prefix || 'data-elb', document, ); - await translateToCoreCollector( - context, - 'page view', + await translateToCoreCollector(context, { + name: 'page view', data, - 'load', - contextData, - ); + context: contextData, + trigger: 'load', + }); // Plus any data-elb elements with load triggers const loadElems = document.querySelectorAll('[data-elbaction*="load"]'); for (const elem of Array.from(loadElems)) { diff --git a/packages/web/sources/browser/src/__tests__/translation.test.ts b/packages/web/sources/browser/src/__tests__/translation.test.ts index c44658854..57b6ab495 100644 --- a/packages/web/sources/browser/src/__tests__/translation.test.ts +++ b/packages/web/sources/browser/src/__tests__/translation.test.ts @@ -2,6 +2,7 @@ import { startFlow } from '@walkeros/collector'; import { createBrowserSource } from './test-utils'; import { translateToCoreCollector } from '../translation'; import type { WalkerOS, Collector } from '@walkeros/core'; +import { createMockLogger } from '@walkeros/core'; import type { Context, Settings } from '../types'; import { createRegistry } from '../trigger'; @@ -63,74 +64,18 @@ describe('Translation Layer', () => { push: mockPush, settings: createTestSettings(prefix), registry: createRegistry(), + logger: createMockLogger(), }); describe('Source Information', () => { test('adds source information to string events', async () => { // Test direct translation call - await translateToCoreCollector( - makeContext(), - 'test event', - { id: 123 }, - undefined, - { page: ['test', 0] }, - ); + await translateToCoreCollector(makeContext(), 'test event', { id: 123 }); expect(mockPush).toHaveBeenCalledWith( expect.objectContaining({ name: 'test event', data: { id: 123 }, - context: { page: ['test', 0] }, - source: { - type: 'browser', - platform: 'web', - url: 'https://example.com/test-page', - referrer: 'https://previous.com/page', - }, - }), - ); - }); - - test('adds source information to flexible format events', async () => { - // Test with number as event (falls through to flexible format) - await translateToCoreCollector( - makeContext(), - 123, - { value: 'test' }, - undefined, - { context: ['info', 0] }, - ); - - expect(mockPush).toHaveBeenCalledWith( - expect.objectContaining({ - name: '123', - data: { value: 'test' }, - context: { context: ['info', 0] }, - source: { - type: 'browser', - platform: 'web', - url: 'https://example.com/test-page', - referrer: 'https://previous.com/page', - }, - }), - ); - }); - - test('normalizes non-object data to empty object (legacy behavior)', async () => { - // Test with primitive data - should become empty object - await translateToCoreCollector( - makeContext(), - 'test event', - 'primitive string data', - undefined, - { page: ['test', 0] }, - ); - - expect(mockPush).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'test event', - data: {}, // Should be empty object, not { value: 'primitive string data' } - context: { page: ['test', 0] }, source: { type: 'browser', platform: 'web', @@ -154,6 +99,7 @@ describe('Translation Layer', () => { push: isolatedMockPush, settings: createTestSettings(), registry: createRegistry(), + logger: createMockLogger(), }, 'walker run', { consent: { marketing: true } }, @@ -185,8 +131,7 @@ describe('Translation Layer', () => { expect(mockPush).not.toHaveBeenCalled(); }); - test('does not add source information to object events', async () => { - // Test object event - should pass through as-is + test('keeps the source an object event carries', async () => { const eventObject = { name: 'custom event', data: { test: true }, @@ -200,8 +145,48 @@ describe('Translation Layer', () => { await translateToCoreCollector(makeContext(), eventObject); - // Object events should pass through unchanged - expect(mockPush).toHaveBeenCalledWith(eventObject); + expect(mockPush).toHaveBeenCalledWith( + expect.objectContaining({ source: eventObject.source }), + ); + }); + + test('leaves the caller event object unchanged', async () => { + const eventObject = { name: 'product view', data: { id: 'P1' } }; + + await translateToCoreCollector(makeContext(), eventObject); + + expect(mockPush).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'product view', + source: expect.objectContaining({ type: 'browser' }), + globals: {}, + }), + ); + expect(eventObject).toEqual({ name: 'product view', data: { id: 'P1' } }); + }); + + test('merges the page globals under the globals an object event carries', async () => { + document.body.innerHTML = + '
'; + + await translateToCoreCollector(makeContext(), { + name: 'product view', + globals: { lang: 'en' }, + }); + + expect(mockPush).toHaveBeenCalledWith( + expect.objectContaining({ globals: { pagegroup: 'shop', lang: 'en' } }), + ); + }); + + test('hands on a copy of the caller data, as written', async () => { + const data = { title: 'Home' }; + + await translateToCoreCollector(makeContext(), 'page view', data); + + const [event] = mockPush.mock.calls[0]; + expect(event.data).toEqual({ title: 'Home' }); + expect(event.data).not.toBe(data); }); test('handles empty referrer', async () => { @@ -250,8 +235,15 @@ describe('Translation Layer', () => { test('events from ELB layer include source information', async () => { // Setup ELB layer with events window.elbLayer = [ - ['product', { id: '123' }, 'click', { position: 1 }], - ['page', { title: 'Test' }, 'load'], + [ + { + name: 'product click', + data: { id: '123' }, + context: { position: [1, 0] }, + trigger: 'click', + }, + ], + ['page view', { title: 'Test' }], ]; // Initialize source - should process existing commands. @@ -267,9 +259,9 @@ describe('Translation Layer', () => { // Should have processed both events with source info expect(mockPush).toHaveBeenCalledWith( expect.objectContaining({ - name: 'product', + name: 'product click', data: { id: '123' }, - context: { position: 1 }, + context: { position: [1, 0] }, trigger: 'click', source: { type: 'browser', @@ -282,9 +274,9 @@ describe('Translation Layer', () => { expect(mockPush).toHaveBeenCalledWith( expect.objectContaining({ - name: 'page', - data: { id: '/test-page', title: 'Test' }, - trigger: 'load', + name: 'page view', + data: { title: 'Test' }, + trigger: '', source: { type: 'browser', platform: 'web', @@ -303,7 +295,7 @@ describe('Translation Layer', () => { // Test by directly calling the event with source information if (elb) { - await elb('product view', { id: 123 }, 'load'); + await elb({ name: 'product view', data: { id: 123 }, trigger: 'load' }); } // Should have processed the event with source info @@ -324,38 +316,6 @@ describe('Translation Layer', () => { }); }); - describe('Context Normalization', () => { - const element = () => { - const el = document.createElement('div'); - el.id = 'test-element'; - el.className = 'test-class'; - return el; - }; - const ordered: WalkerOS.OrderedProperties = { - page: ['home', 0], - section: ['hero', 1], - }; - - it.each([ - ['undefined', () => undefined, {}], - ['an element', element, {}], - ['an empty object', () => ({}), {}], - ['valid ordered properties', () => ordered, ordered], - ])('normalizes %s context', async (_label, build, expected) => { - await translateToCoreCollector( - makeContext(), - 'test event', - { id: 123 }, - undefined, - build(), - ); - - expect(mockPush).toHaveBeenCalledWith( - expect.objectContaining({ context: expected }), - ); - }); - }); - describe('Edge Cases', () => { test('handles null/undefined referrer gracefully', async () => { // Mock null referrer diff --git a/packages/web/sources/browser/src/__tests__/trigger.test.ts b/packages/web/sources/browser/src/__tests__/trigger.test.ts index 6471bbfb8..e71a03f10 100644 --- a/packages/web/sources/browser/src/__tests__/trigger.test.ts +++ b/packages/web/sources/browser/src/__tests__/trigger.test.ts @@ -1,5 +1,5 @@ import type { Elb } from '@walkeros/core'; -import { isObject } from '@walkeros/core'; +import { isObject, createMockLogger } from '@walkeros/core'; import type { Context, Registry, Settings } from '../types'; import { createRegistry, @@ -61,6 +61,7 @@ describe('Trigger System', () => { push: mockElb, settings: createTestSettings(prefix), registry, + logger: createMockLogger(), }); beforeEach(() => { @@ -162,7 +163,13 @@ describe('Trigger System', () => { }; await handleTrigger( - { elb: mockElb, push: mockElb, settings: testSettings, registry }, + { + elb: mockElb, + push: mockElb, + settings: testSettings, + registry, + logger: createMockLogger(), + }, element, Triggers.Click, ); @@ -520,6 +527,7 @@ describe('Trigger System', () => { push: mockElb, settings: rootSettings, registry, + logger: createMockLogger(), }; initGlobalTrigger(rootContext); @@ -534,6 +542,7 @@ describe('Trigger System', () => { push: mockElb, settings: containerSettings, registry, + logger: createMockLogger(), }; initScopeTrigger(containerContext); @@ -658,6 +667,7 @@ describe('Trigger System', () => { push: mockElb, settings, registry, + logger: createMockLogger(), }; initScopeTrigger(context); diff --git a/packages/web/sources/browser/src/__tests__/triggerVisible.test.ts b/packages/web/sources/browser/src/__tests__/triggerVisible.test.ts index c98950410..438d03141 100644 --- a/packages/web/sources/browser/src/__tests__/triggerVisible.test.ts +++ b/packages/web/sources/browser/src/__tests__/triggerVisible.test.ts @@ -1,4 +1,5 @@ import type { Elb } from '@walkeros/core'; +import { createMockLogger } from '@walkeros/core'; import type { Context, Registry } from '../types'; import { initVisibilityTracking, @@ -30,6 +31,7 @@ const createTestContext = (elb: Elb.Fn, prefix = 'data-elb'): Context => ({ elbLayer: false, }, registry, + logger: createMockLogger(), }); // A single `await Promise.resolve()` advances the microtask queue by exactly one @@ -317,6 +319,7 @@ describe('triggerVisible', () => { elbLayer: false, }, registry, + logger: createMockLogger(), }); test('3a observes and fires a sub-scope element via the shared per-document observer', async () => { diff --git a/packages/web/sources/browser/src/__tests__/visibleGeometry.test.ts b/packages/web/sources/browser/src/__tests__/visibleGeometry.test.ts index 495710647..6b0ca92e3 100644 --- a/packages/web/sources/browser/src/__tests__/visibleGeometry.test.ts +++ b/packages/web/sources/browser/src/__tests__/visibleGeometry.test.ts @@ -1,4 +1,5 @@ import type { Elb } from '@walkeros/core'; +import { createMockLogger } from '@walkeros/core'; import type { Context, Registry } from '../types'; import { isEligible, @@ -190,6 +191,7 @@ const createTestContext = (elb: Elb.Fn, prefix = 'data-elb'): Context => ({ elbLayer: false, }, registry, + logger: createMockLogger(), }); // A single `await Promise.resolve()` advances the microtask queue by exactly diff --git a/packages/web/sources/browser/src/elbLayer.ts b/packages/web/sources/browser/src/elbLayer.ts index 5a00e0101..9954deafa 100644 --- a/packages/web/sources/browser/src/elbLayer.ts +++ b/packages/web/sources/browser/src/elbLayer.ts @@ -157,8 +157,10 @@ export function createElbLayer( const route = (item: unknown): Promise => { if (Array.isArray(item)) { - const [eventOrCommand, ...rest] = item; - return translateToCoreCollector(context, eventOrCommand, ...rest); + // Every argument is forwarded, so one the call form does not take is + // reported like a direct call's. + const [eventOrCommand, data, ...rest] = item; + return translateToCoreCollector(context, eventOrCommand, data, ...rest); } return translateToCoreCollector(context, item); }; diff --git a/packages/web/sources/browser/src/examples/step.ts b/packages/web/sources/browser/src/examples/step.ts index ccf3d6806..e58c6234c 100644 --- a/packages/web/sources/browser/src/examples/step.ts +++ b/packages/web/sources/browser/src/examples/step.ts @@ -26,8 +26,6 @@ export const pageView: Flow.StepExample = { }, context: {}, globals: {}, - nested: undefined, - custom: undefined, trigger: 'load', source: { type: 'browser', @@ -62,8 +60,6 @@ export const routeChange: Flow.StepExample = { }, context: {}, globals: {}, - nested: undefined, - custom: undefined, trigger: 'load', source: { type: 'browser', @@ -85,8 +81,6 @@ export const routeChange: Flow.StepExample = { }, context: {}, globals: {}, - nested: undefined, - custom: undefined, trigger: 'load', source: { type: 'browser', @@ -205,8 +199,6 @@ export const nestedEntities: Flow.StepExample = { }, context: {}, globals: {}, - nested: undefined, - custom: undefined, trigger: 'load', source: { type: 'browser', diff --git a/packages/web/sources/browser/src/examples/trigger.ts b/packages/web/sources/browser/src/examples/trigger.ts index 794601286..a43a30ef0 100644 --- a/packages/web/sources/browser/src/examples/trigger.ts +++ b/packages/web/sources/browser/src/examples/trigger.ts @@ -137,6 +137,7 @@ const createTrigger: Trigger.CreateFn = async ( push: flow.collector.push, settings: settingsFrom(source.config.settings), registry: createRegistry(), + logger: flow.collector.logger, }; // 7. Call handleTrigger directly, same convergence point as production diff --git a/packages/web/sources/browser/src/index.ts b/packages/web/sources/browser/src/index.ts index aad1e221d..b6ca11834 100644 --- a/packages/web/sources/browser/src/index.ts +++ b/packages/web/sources/browser/src/index.ts @@ -1,6 +1,6 @@ import type { Source, On } from '@walkeros/core'; import type { Context, Scope, Types, Env } from './types'; -import type { BrowserPush, BrowserArguments } from './types/elb'; +import type { BrowserPush } from './types/elb'; import { isString } from '@walkeros/core'; import { createRegistry, @@ -66,7 +66,7 @@ export const sourceBrowser: Source.Init = async (context) => { const { config, env, logger } = context; // `env.push` is the collector's source-pipeline entry for EVENTS. Aliased // because this module's own `push` (below) is the source's outward-facing - // BrowserPush, which accepts commands and flexible argument forms too. + // BrowserPush, which accepts commands and the string form too. const { elb, push: pipelinePush, command, window, document } = env; const userSettings = config?.settings || {}; @@ -135,10 +135,7 @@ export const sourceBrowser: Source.Init = async (context) => { previousHref = href || actualWindow?.location.href; return translateToCoreCollector( { ...translationContext, href }, - 'page view', - data, - 'load', - contextData, + { name: 'page view', data, context: contextData, trigger: 'load' }, ); }; @@ -212,7 +209,7 @@ export const sourceBrowser: Source.Init = async (context) => { ); } else { const windowElb: BrowserPush = adoptedController - ? (((...args: Parameters) => + ? (((...args: unknown[]) => adoptedController.intake(args)) satisfies BrowserPush) : push; installedWindowElb = mark(windowElb, registry); @@ -268,18 +265,19 @@ export const sourceBrowser: Source.Init = async (context) => { } }; - const push: BrowserPush = ((...args: Parameters) => { - const [event, data, options, context, nested, custom] = args; - return translateToCoreCollector( + // Every argument is forwarded, so one the call form does not take is + // reported, not dropped unseen. + const push: BrowserPush = (( + event?: unknown, + data?: unknown, + ...rest: unknown[] + ) => + translateToCoreCollector( translationContext, event, data, - options, - context, - nested, - custom, - ); - }) satisfies BrowserPush; + ...rest, + )) satisfies BrowserPush; // Return stateless source instance with event handler and push method return { diff --git a/packages/web/sources/browser/src/translation.ts b/packages/web/sources/browser/src/translation.ts index 22abce167..ace2a15fd 100644 --- a/packages/web/sources/browser/src/translation.ts +++ b/packages/web/sources/browser/src/translation.ts @@ -1,27 +1,23 @@ -import type { WalkerOS, Elb, Collector } from '@walkeros/core'; -import { isString, isObject, isElementOrDocument } from '@walkeros/core'; -import { createPushResult } from '@walkeros/collector'; -import type { - BrowserPushData, - BrowserPushOptions, - BrowserPushContext, -} from './types/elb'; +import type { WalkerOS, Elb } from '@walkeros/core'; +import { isString, isObject } from '@walkeros/core'; +import { createPushResult, ELB_IGNORED_ARGUMENTS } from '@walkeros/collector'; import type { Context, Settings, InitScope, Scope } from './types'; -import { getEntities, getGlobals } from './walker'; +import { getGlobals } from './walker'; import { isDomScope } from './scope'; /** - * Translation layer that converts flexible browser source inputs - * to the strict core collector format + * Translation layer from what the page passes to `elb` to what the collector + * takes: `elb(partialEvent)`, `elb('walker ', data)` and the string + * form `elb('entity action', data)`, which is exactly `{ name, data }`. + * + * An argument none of these forms takes is ignored; the call still runs and + * is reported at ERROR, once per source and event or command name. */ export function translateToCoreCollector( context: Context, eventOrCommand: unknown, - data?: BrowserPushData, - options?: BrowserPushOptions, - pushContext?: BrowserPushContext, - nested?: WalkerOS.Entities, - custom?: WalkerOS.Properties, + data?: unknown, + ...rest: unknown[] ): Promise { // Events exit via `push` (the source pipeline); `walker *` commands exit via // `elb` (the raw collector). Keeping them apart is what lets a source declare @@ -39,6 +35,7 @@ export function translateToCoreCollector( // `initScope` arrives on `context` from the source factory to avoid a // static import of `./trigger` (which would form a load-time cycle). if (isString(eventOrCommand) && eventOrCommand.startsWith('walker ')) { + if (hasExtra(rest)) reportIgnored(context, eventOrCommand); if (eventOrCommand === 'walker init' && context.initScope) { const scopes = normalizeInitScopes(data, settings); for (const scope of scopes) { @@ -52,101 +49,72 @@ export function translateToCoreCollector( return elb(eventOrCommand, data as WalkerOS.Properties); } - // Handle event objects - add source and globals if missing - if (isObject(eventOrCommand)) { - const event = eventOrCommand; - if (!event.source && settings.scope) { - const scopeDoc = ((settings.scope as Element).ownerDocument || - settings.scope) as Document; - const scopeWin = scopeDoc.defaultView!; - event.source = getBrowserSource(scopeWin, scopeDoc, context.href); - } - - // Add globals if not already present - if (!event.globals && globalsScope) { - event.globals = isGlobalsScope(globalsScope) - ? getGlobals(settings.prefix, globalsScope) - : {}; - } - - return push(event); - } - - // Extract entity name from event string - const [entity] = String( - isObject(eventOrCommand) ? eventOrCommand.name : eventOrCommand, - ).split(' '); - - // Get data and context either from elements or parameters - let eventData = isObject(data) ? (data as WalkerOS.Properties) : {}; - let eventContext: WalkerOS.OrderedProperties = {}; - - let elemParameter: undefined | Element; - let dataIsElem = false; - - // Check if data parameter is an element - if (isElementOrDocument(data)) { - elemParameter = data as Element; - dataIsElem = true; - } - - // Check if contextData parameter is an element - if (isElementOrDocument(pushContext)) { - elemParameter = pushContext as Element; - } else if (isObject(pushContext) && Object.keys(pushContext).length) { - eventContext = pushContext as WalkerOS.OrderedProperties; - } - - // Extract data from element if provided - if (elemParameter) { - const entityObj = getEntities( - settings.prefix || 'data-elb', - elemParameter, - ).find((obj) => obj.entity === entity); - if (entityObj) { - if (dataIsElem) eventData = entityObj.data; - if (entityObj.context) eventContext = entityObj.context; - } - } - // Derive win/doc from scope for browser-specific APIs const scopeDoc = settings.scope ? (((settings.scope as Element).ownerDocument || settings.scope) as Document) : undefined; const scopeWin = scopeDoc?.defaultView; - - // Special handling for page events - if (entity === 'page' && scopeWin) { - eventData.id = - eventData.id || - (context.href ? new URL(context.href) : scopeWin.location).pathname; - } + const source = + scopeWin && scopeDoc + ? getBrowserSource(scopeWin, scopeDoc, context.href) + : undefined; // Collect globals from the source root scope - const eventGlobals = + const pageGlobals = globalsScope && isGlobalsScope(globalsScope) ? getGlobals(settings.prefix, globalsScope) : {}; - // Build unified event from various elb usage patterns + // Handle event objects - add source and globals + if (isObject(eventOrCommand)) { + if (data !== undefined || hasExtra(rest)) + reportIgnored(context, String(eventOrCommand.name)); + + // Shallow-clone so adapter writes never leak onto the caller's object + const event: WalkerOS.DeepPartialEvent = { ...eventOrCommand }; + if (!event.source && source) event.source = source; + + // The page globals sit under the event's own, which win per key + event.globals = { ...pageGlobals, ...event.globals }; + + return push(event); + } + + // The string form, on a copy of the caller's data + const name = String(eventOrCommand || ''); + if (hasExtra(rest) || (data !== undefined && !isObject(data))) + reportIgnored(context, name); + const event: WalkerOS.DeepPartialEvent = { - name: String(eventOrCommand || ''), - data: eventData, - context: eventContext, - globals: eventGlobals, - nested, - custom, - trigger: isString(options) ? options : '', - source: - scopeWin && scopeDoc - ? getBrowserSource(scopeWin, scopeDoc, context.href) - : undefined, + name, + data: isObject(data) ? { ...(data as WalkerOS.Properties) } : {}, + globals: pageGlobals, + trigger: '', + source, }; return push(event); } +// An argument past `data` counts once it carries a value. +function hasExtra(rest: unknown[]): boolean { + return rest.some((arg) => arg !== undefined); +} + +/** + * Report that `elb` was called with an argument the call form does not take, + * once per source and name, so one event's report never hides another's. The + * names live on the source's registry, which every scope-aligned copy of the + * context shares by reference. + */ +function reportIgnored(context: Context, name: string): void { + const { registry, logger } = context; + if (registry.ignoredArguments.has(name)) return; + registry.ignoredArguments.add(name); + logger.error(ELB_IGNORED_ARGUMENTS, { event: name }); +} + /** * Create source information for browser events */ diff --git a/packages/web/sources/browser/src/trigger.ts b/packages/web/sources/browser/src/trigger.ts index 74edb8866..3bc291c83 100644 --- a/packages/web/sources/browser/src/trigger.ts +++ b/packages/web/sources/browser/src/trigger.ts @@ -35,6 +35,7 @@ export function createRegistry(): Registry { elements: new WeakMap(), visibility: new WeakMap(), destroyed: false, + ignoredArguments: new Set(), }; } diff --git a/packages/web/sources/browser/src/types/elb.ts b/packages/web/sources/browser/src/types/elb.ts index c40c2f03a..13dfba7ed 100644 --- a/packages/web/sources/browser/src/types/elb.ts +++ b/packages/web/sources/browser/src/types/elb.ts @@ -1,12 +1,13 @@ import type { Collector, Elb, WalkerOS } from '@walkeros/core'; -import type { DestinationWeb } from '@walkeros/web-core'; // `./scope` is a leaf module (DOM globals only), so importing from it keeps this // file free of the circular import that the forward declarations below avoid. -import type { Scope, InitScope } from './scope'; +import type { InitScope } from './scope'; export type Trigger = string; -// Browser-specific push interface (can be more flexible) +// Browser push: an event object, a walker command, or the string form, which +// is exactly `{ name, data }`. Trigger, context, nested and custom go inside +// the event object. export interface BrowserPush< R = Promise, > extends Elb.WalkerCommands { @@ -16,45 +17,9 @@ export interface BrowserPush< (event: 'walker init', scope: InitScope | InitScope[]): R; (event: 'walker run', state?: Partial): R; - // Browser-flexible event form - ( - event?: unknown, - data?: BrowserPushData, - options?: BrowserPushOptions, - context?: BrowserPushContext, - nested?: WalkerOS.Entities, - custom?: WalkerOS.Properties, - ): R; + (event: string, data?: WalkerOS.Properties): R; } -// Browser-specific flexible arguments -export type BrowserArguments> = ( - event?: unknown, - data?: BrowserPushData, - options?: BrowserPushOptions, - context?: BrowserPushContext, - nested?: WalkerOS.Entities, - custom?: WalkerOS.Properties, -) => R; - -// Browser-specific data types -export type BrowserPushData = - | Elb.PushData // Core types - | DestinationWeb.Destination - | DestinationWeb.Init // Web-specific - | Scope - | Array // Web-specific - | Trigger // Web-specific - | string - | object // Flexible for browser source - | IArguments; // Support for arguments object - -// Browser-specific options -export type BrowserPushOptions = Trigger | string | object; - -// Browser-specific context -export type BrowserPushContext = WalkerOS.OrderedProperties | Element; - // Re-export core types export type PushResult = Elb.PushResult; export type Layer = Elb.Layer | IArguments; diff --git a/packages/web/sources/browser/src/types/index.ts b/packages/web/sources/browser/src/types/index.ts index e52999946..314f0bf78 100644 --- a/packages/web/sources/browser/src/types/index.ts +++ b/packages/web/sources/browser/src/types/index.ts @@ -79,7 +79,9 @@ export interface Context { // field with a module-level fallback would put the containers back in module // scope, which is the state parallel sources must not share. registry: Registry; - logger?: Logger.Instance; + // Required: the ERROR for an ignored `elb` argument goes here, and the + // source always has the collector's logger. + logger: Logger.Instance; // Scope travels in context.settings.scope; the implementation reads it from // there. Called with a single, scope-aligned context for `walker init`. initScope?: (context: Context) => void; @@ -171,4 +173,7 @@ export interface Registry { // deferred past init (the DOM-ready hook) still fires, so every path that // wires, scans or emits reads this first. destroyed: boolean; + // The event and command names this source reported an ignored `elb` + // argument for, so the page sees each name's ERROR once, not on every call. + ignoredArguments: Set; } diff --git a/packages/web/sources/browser/src/walker.ts b/packages/web/sources/browser/src/walker.ts index 270fe7da4..c17bad65c 100644 --- a/packages/web/sources/browser/src/walker.ts +++ b/packages/web/sources/browser/src/walker.ts @@ -227,6 +227,9 @@ export function getPageViewData( if (loc.search) data.search = loc.search; if (loc.hash) data.hash = loc.hash; + // A tagged page id wins; untagged, the page is its path. + if (!data.id) data.id = loc.pathname; + // @TODO get all nested entities return [data, context]; } diff --git a/skills/walkeros-debugging/SKILL.md b/skills/walkeros-debugging/SKILL.md index a59f9578f..40d88e29b 100644 --- a/skills/walkeros-debugging/SKILL.md +++ b/skills/walkeros-debugging/SKILL.md @@ -91,7 +91,7 @@ const destination = { ```typescript // Event pushed -elb('product view', { id: 'P123' }); +elb({ name: 'product view', data: { id: 'P123' } }); // Mapping expects different name mapping: { diff --git a/skills/walkeros-understanding-events/SKILL.md b/skills/walkeros-understanding-events/SKILL.md index 313ec1a46..52cf74b46 100644 --- a/skills/walkeros-understanding-events/SKILL.md +++ b/skills/walkeros-understanding-events/SKILL.md @@ -209,21 +209,32 @@ Transformation to vendor formats happens in **mapping**, not in event creation. ## Creating Events +`elb` takes a partial event: `name` is required, every other field is optional. +The collector adds `id`, `timestamp`, `entity`, `action` and `source`, and +merges its own `globals`, `user` and `consent` key by key (the event's values +win). + ```typescript -import { elb } from '@walkeros/collector'; - -// Basic event -await elb('page view', { title: 'Home', path: '/' }); - -// With all properties -await elb( - 'product add', - { id: 'P123', price: 99 }, // data - { stage: ['cart', 1] }, // context (optional) - { currency: 'USD' }, // globals (optional) -); +import { startFlow } from '@walkeros/collector'; + +const { elb } = await startFlow({ + /* destinations */ +}); + +const result = await elb({ + name: 'product add', + data: { id: 'P123', price: 99 }, + context: { stage: ['cart', 1] }, + globals: { currency: 'USD' }, +}); + +// A rejected event reaches no destination, e.g. 'Event name is invalid' +if (!result.ok) console.error(result.error); ``` +Every event uses this partial event form. `walker` commands keep their string +name: `elb('walker consent', { marketing: true })`. + ## Related Skills - [walkeros-understanding-mapping](../walkeros-understanding-mapping/SKILL.md) - diff --git a/skills/walkeros-understanding-flow/SKILL.md b/skills/walkeros-understanding-flow/SKILL.md index 15c38e27c..ee66dc7f6 100644 --- a/skills/walkeros-understanding-flow/SKILL.md +++ b/skills/walkeros-understanding-flow/SKILL.md @@ -68,8 +68,12 @@ interface Flow { | Collector | `push(event) → void` | Process and route | | Destination | `push(event, context) → void` | Transform and deliver | -The `elb()` function is an alias for `collector.push` - used for component -wiring. +The collector's `elb()` takes a partial event +(`elb({ name: 'page view', data })`) or a `walker` command; `collector.push` +takes event objects only. `startFlow` returns the primary source's `push` as +`elb`, or the collector's `elb` when no source is configured. The browser +source's push takes the same partial event; a server source's push is its +request handler. ### startFlow Helper diff --git a/skills/walkeros-understanding-sources/SKILL.md b/skills/walkeros-understanding-sources/SKILL.md index 94a3c76ac..324dd22df 100644 --- a/skills/walkeros-understanding-sources/SKILL.md +++ b/skills/walkeros-understanding-sources/SKILL.md @@ -166,11 +166,11 @@ http('handler', source.push); - `env.push` is **outward**: where the source sends the events it produced. A source can legitimately have both, and they need not be related: the browser -source's `Instance.push` is a flexible `BrowserPush` that accepts commands and -argument forms, while its events leave via `env.push`. The dataLayer and session -sources keep `Instance.push: env.elb`, because callers do not feed them events -at all — they capture from `dataLayer.push` and from session detection -respectively. +source's `Instance.push` is a `BrowserPush` that accepts event objects, `walker` +commands and the string form, while its events leave via `env.push`. The +dataLayer and session sources keep `Instance.push: env.elb`, because callers do +not feed them events at all: they capture from `dataLayer.push` and from session +detection respectively. When a test or integration code needs to invoke a source's `push` through the collector bag, `collector.sources` erases the per-source generic on read. Use diff --git a/skills/walkeros-writing-documentation/SKILL.md b/skills/walkeros-writing-documentation/SKILL.md index 48d2c7f9f..cb9e18ff9 100644 --- a/skills/walkeros-writing-documentation/SKILL.md +++ b/skills/walkeros-writing-documentation/SKILL.md @@ -94,10 +94,15 @@ It extracts canonical tokens from the Tier-1 source and asserts the docs snippets contain them, tolerating intentional doc simplifications (dropping the `seen` capture, arrow `push` instead of a method). `first-event.ts` is pinned on the quickstart, integrated-mode, and README pages, plus the GA4 ecommerce page; -`react.mdx` is pinned shape-only (it has no `elb('page view')` push). +`react.mdx` is pinned shape-only (it pushes no `page view`). The doc-lint also runs these structural and coherence checks: +- **elb event form** (docs, skills, READMEs): every event is pushed as a partial + event, `elb({ name: 'entity action', data })`. A quoted `"entity action"` + string as the first `elb` argument is an error; `walker` commands keep their + string name (`elb('walker consent', ...)`). + - **Bundled-snippet platform guard**: every JSON flow with `bundle` carries `config.platform` (getting-started, README, flow reference). - **Docker env guard**: quickstart Docker snippets use `BUNDLE`, not `FLOW`. @@ -398,6 +403,7 @@ Note: `hints` is a direct export (not `* as`), because it's already a ### Content - [ ] All event names use `"entity action"` format with space +- [ ] Events are pushed as partial events: `elb({ name, data })` - [ ] Flow config shown as primary usage pattern - [ ] Examples are complete and copy-pasteable - [ ] Includes imports in code examples diff --git a/website/docs/collector/commands.mdx b/website/docs/collector/commands.mdx index daa448049..1b96926e5 100644 --- a/website/docs/collector/commands.mdx +++ b/website/docs/collector/commands.mdx @@ -441,7 +441,7 @@ return params.result; }, }); -elb('entity action'); +elb({ name: 'page view' }); // Output: // walker exec time 1`} diff --git a/website/docs/destinations/web/gtag/ga4.mdx b/website/docs/destinations/web/gtag/ga4.mdx index 8476d6343..7e0addc9f 100644 --- a/website/docs/destinations/web/gtag/ga4.mdx +++ b/website/docs/destinations/web/gtag/ga4.mdx @@ -142,7 +142,10 @@ via a `this` loop. Pushing a `product add` event: diff --git a/website/docs/destinations/web/snowplow.mdx b/website/docs/destinations/web/snowplow.mdx index 1d42d1ed3..04fbd23cc 100644 --- a/website/docs/destinations/web/snowplow.mdx +++ b/website/docs/destinations/web/snowplow.mdx @@ -324,26 +324,29 @@ The destination supports all media events and contexts. ### Basic usage diff --git a/website/docs/getting-started/event-model.mdx b/website/docs/getting-started/event-model.mdx index 768a5756f..91cb851ce 100644 --- a/website/docs/getting-started/event-model.mdx +++ b/website/docs/getting-started/event-model.mdx @@ -200,6 +200,48 @@ event `id` is a W3C `span_id` (16 lowercase hex characters) generated per event. The collector sets `trace`, `count`, and `id` only when they are absent, so an event forwarded from web to server keeps its original values. +## Push an event {#push-an-event} + +Pass `elb` a partial event. `name` is required, every other field is optional, +and the collector completes the event before any destination receives it. + + + +| Field | In your partial event | Added by the collector | +| --- | --- | --- | +| `name` | Required, as `"entity action"` | | +| `entity`, `action` | | The first two words of `name` | +| `data`, `context`, `nested`, `custom`, `trigger` | Optional | Empty when missing | +| `globals`, `user`, `consent` | Optional, for this event only | Its own state, merged key by key; your values win | +| `id`, `timestamp`, `timing`, `source` | | Set when missing | + +A source can add fields on the way in: the browser source sets `source.url`, +`source.referrer` and the globals tagged on the page. + +`elb` resolves with the push result. `done`, `queued` and `failed` list the +destinations by id, and `event` is the completed event. `ok` is `false` when a +destination failed or the event was rejected. A rejected event reaches no +destination: a name without an action, such as `'purchase'`, returns +`{ ok: false, invalid: true, error: 'Event name is invalid' }`. + +A first argument that starts with `walker `, such as +`elb('walker consent', { analytics: true })`, is a +[command](/docs/collector/commands), not an event. + +`startFlow` returns `elb`, which is the browser source's own when it is the +[primary source](/docs/sources#primary-source). On a web page the +browser source also installs it as `window.elb` +([browser source commands](/docs/sources/web/browser/commands#elb)). + ## Strengths ### Structured flexibility diff --git a/website/docs/getting-started/ga4-ecommerce.mdx b/website/docs/getting-started/ga4-ecommerce.mdx index 3a6919ccd..7b5e2a574 100644 --- a/website/docs/getting-started/ga4-ecommerce.mdx +++ b/website/docs/getting-started/ga4-ecommerce.mdx @@ -43,7 +43,7 @@ const { elb } = await startFlow({ }, }); -await elb('page view', { title: 'Home' }); +await elb({ name: 'page view', data: { title: 'Home' } }); // -> logs: Event: page view`} language="javascript" /> @@ -70,7 +70,7 @@ const { elb } = await startFlow({ }, }); -await elb('page view', { title: 'Home' });`} +await elb({ name: 'page view', data: { title: 'Home' } });`} language="typescript" /> @@ -114,7 +114,10 @@ is shaped into a GA4 `items` array via a `this` loop: Pushing a `product add` event: diff --git a/website/docs/getting-started/modes/integrated.mdx b/website/docs/getting-started/modes/integrated.mdx index 1220ba0d3..4d86ba269 100644 --- a/website/docs/getting-started/modes/integrated.mdx +++ b/website/docs/getting-started/modes/integrated.mdx @@ -35,7 +35,7 @@ const { elb } = await startFlow({ }, }); -await elb('page view', { title: 'Home' }); +await elb({ name: 'page view', data: { title: 'Home' } }); // -> logs: Event: page view`} language="typescript" /> @@ -147,19 +147,27 @@ sources: { This differs from Bundled mode where you use `package:` with a string reference. -### The `elb` Function +### The `elb` function {#the-elb-function} -`startFlow()` returns an `elb` function for tracking events: +`startFlow()` returns `elb`. Pass it a partial event: `name` is required, and +the collector adds the rest, such as `id`, `timestamp` and `source`. ```typescript const { elb } = await startFlow({ ... }); -// Track events with entity-action format -elb('page view', { title: 'Home' }); -elb('product add', { id: 'abc', name: 'Widget', price: 29.99 }); -elb('order complete', { total: 99.99, currency: 'USD' }); +const result = await elb({ + name: 'product add', + data: { id: 'abc', name: 'Widget', price: 29.99 }, +}); + +if (!result.ok) console.error(result.error); ``` +`result.done` lists the destinations that received the event, `result.queued` +those waiting for consent, and `result.failed` those whose push failed. The +[event model](/docs/getting-started/event-model#push-an-event) lists the fields +a partial event takes and what the collector adds. + ### Type safety Each step is typed by the package you pass as `code`. Its settings, the @@ -261,7 +269,7 @@ export function WalkerProvider({ children }: { children: React.ReactNode }) { The basic setup above logs every event it receives. Run it (Node, or your app's dev server) and push an event: logs: Event: page view`} language="typescript" /> diff --git a/website/docs/getting-started/quickstart/index.mdx b/website/docs/getting-started/quickstart/index.mdx index 9a83f1932..06ee5741b 100644 --- a/website/docs/getting-started/quickstart/index.mdx +++ b/website/docs/getting-started/quickstart/index.mdx @@ -47,7 +47,7 @@ const { elb } = await startFlow({ }, }); -await elb('page view', { title: 'Home' }); +await elb({ name: 'page view', data: { title: 'Home' } }); // -> logs: Event: page view`} language="javascript" /> diff --git a/website/docs/getting-started/quickstart/nextjs.mdx b/website/docs/getting-started/quickstart/nextjs.mdx index 4d0574d76..4f236d391 100644 --- a/website/docs/getting-started/quickstart/nextjs.mdx +++ b/website/docs/getting-started/quickstart/nextjs.mdx @@ -177,7 +177,11 @@ function ProductPage({ product }: { product: { id: number; name: string } }) { .get()} >

{product.name}

-
diff --git a/website/docs/getting-started/quickstart/react.mdx b/website/docs/getting-started/quickstart/react.mdx index e3cdc0740..81efba28b 100644 --- a/website/docs/getting-started/quickstart/react.mdx +++ b/website/docs/getting-started/quickstart/react.mdx @@ -213,9 +213,9 @@ Use the `elb` function for custom events: function CheckoutForm({ total, itemCount }: { total: number; itemCount: number }) { const handleSubmit = (event: FormEvent) => { event.preventDefault(); - window.elb('checkout complete', { - total, - items: itemCount, + window.elb({ + name: 'checkout complete', + data: { total, items: itemCount }, }); }; diff --git a/website/docs/getting-started/quickstart/walkerjs.mdx b/website/docs/getting-started/quickstart/walkerjs.mdx index 44dfb3338..90f7ea796 100644 --- a/website/docs/getting-started/quickstart/walkerjs.mdx +++ b/website/docs/getting-started/quickstart/walkerjs.mdx @@ -177,7 +177,7 @@ this stub to your page's ``, before your own code: diff --git a/website/docs/sources/index.mdx b/website/docs/sources/index.mdx index c812d1e33..39657ff09 100644 --- a/website/docs/sources/index.mdx +++ b/website/docs/sources/index.mdx @@ -184,7 +184,7 @@ destinations: { // elb is now browser.push (enhanced with DOM commands) // Use elb for manual event tracking -await elb('product view', { id: 'P123', name: 'Laptop' });`} +await elb({ name: 'product view', data: { id: 'P123', name: 'Laptop' } });`} language="typescript" /> @@ -194,7 +194,7 @@ await elb('product view', { id: 'P123', name: 'Laptop' });`} * **With sources**: Returns first source's `push` method (enhanced features) * **Multiple sources**: First source is primary by default -* **No sources**: Returns `collector.push` (basic functionality) +* **No sources**: Returns the collector's own `elb` (events and walker commands, no DOM commands) ### Override primary source diff --git a/website/docs/sources/web/browser/commands.mdx b/website/docs/sources/web/browser/commands.mdx index bd1feaad7..a7c29fd73 100644 --- a/website/docs/sources/web/browser/commands.mdx +++ b/website/docs/sources/web/browser/commands.mdx @@ -26,26 +26,13 @@ browserSource --> collector ## elb -The browser source provides an enhanced `elb` function that supports flexible -argument patterns and browser-specific features. +The browser source puts its `elb` function on the page. Push an event as a +[partial event](/docs/getting-started/event-model#push-an-event), and a +command by its name: - -Usage options: - - @@ -55,7 +42,18 @@ installs it: a source that finds the name held by another one logs an error, skips the install, and keeps capturing through its own `push`. Set `elb: false` to install no window property at all, which is the posture for an embedded source that should leave no page-window footprint. Calls return a promise that -resolves with the processing result. +resolves with the [push result](/docs/getting-started/event-model#push-an-event). + +Code that can run before the source starts, such as a page script ahead of an +`async` walker.js, defines this stub first. It queues every call in +`window.elbLayer`, and the source processes the queue once it starts: + + See [Multiple sources on one page](/docs/sources/web/browser#multiple-sources-on-one-page) @@ -134,10 +132,11 @@ event setup. It will: language="javascript" /> -A run accepts a partial state parameter: +A run accepts state to set before it starts, any of `consent`, `user`, +`globals` and `custom`: diff --git a/website/docs/sources/web/browser/index.mdx b/website/docs/sources/web/browser/index.mdx index c2b525a7f..c64eea888 100644 --- a/website/docs/sources/web/browser/index.mdx +++ b/website/docs/sources/web/browser/index.mdx @@ -44,8 +44,8 @@ import { sourceSession } from '@walkeros/web-source-session'; const { collector, elb } = await startFlow({ sources: { - browser: sourceBrowser, - session: sourceSession, // Add for session tracking + browser: { code: sourceBrowser }, + session: { code: sourceSession }, // Add for session tracking }, });`} language="typescript" @@ -82,24 +82,25 @@ Add to your `flow.json` sources: ## Enhanced elb -The browser source returns an enhanced `elb` function with additional features: +With a browser source in `sources`, the `elb` that `startFlow` returns is the +browser source's own, and the source installs it as `window.elb` as well. It +takes the same [partial event](/docs/getting-started/event-model#push-an-event) +as the collector's `elb`: **Browser source elb adds:** +* **Page context**: the tagged globals on every event, plus `source.url` and `source.referrer` unless the event brings its own `source` * **DOM commands**: `walker init` for asynchronous DOM element loading -* **Flexible arguments**: support for multiple argument patterns * **elbLayer integration**: automatic processing of queued commands -* **Element parameters**: support for element parameters in commands See [Commands](/docs/sources/web/browser/commands) for complete API documentation. diff --git a/website/docs/sources/web/browser/tagging/html-attributes.mdx b/website/docs/sources/web/browser/tagging/html-attributes.mdx index e9e6ac9eb..cb5b1e6f0 100644 --- a/website/docs/sources/web/browser/tagging/html-attributes.mdx +++ b/website/docs/sources/web/browser/tagging/html-attributes.mdx @@ -308,8 +308,8 @@ inside it goes to the next entity above it, or to `page`. Use `shopping_cart` instead. Follow the same rule for action names, e.g. `add_to_cart`. Mapping rules and -`elb('entity action')` read entity and action as the first two words of the -event name, so `add to cart` would be matched as `add`. +the collector read entity and action as the first two words of the event name, +so `add to cart` would be matched as `add`. ::: diff --git a/website/docs/sources/web/browser/tagging/javascript.mdx b/website/docs/sources/web/browser/tagging/javascript.mdx index 8df2479f8..138c5fae3 100644 --- a/website/docs/sources/web/browser/tagging/javascript.mdx +++ b/website/docs/sources/web/browser/tagging/javascript.mdx @@ -18,38 +18,37 @@ attributes. ## Basic usage - - -## Event structure - -The elb function accepts: - -1. **Event name**: `"entity action"` format (required) -2. **Data object**: Properties for the event (optional) -3. **Context**: Contextual information (optional) -4. **Trigger**: The trigger name (optional) -5. **Nested**: Nested entities (optional) +Pass `elb` a [partial event](/docs/getting-started/event-model#push-an-event): +`name` is required, every other field is optional. +The browser source adds `source.url`, `source.referrer` and the globals tagged +on the page. Each call resolves with the +[push result](/docs/getting-started/event-model#push-an-event). + ## Examples ### Page events +The automatic page view uses the path as `data.id`; set it the same way: + @@ -58,11 +57,14 @@ The elb function accepts: { - elb('form submit', { - id: form.id, - type: form.dataset.formType + elb({ + name: 'form submit', + data: { id: form.id, type: form.dataset.formType }, + trigger: 'submit', }); });`} language="javascript" @@ -87,9 +90,9 @@ The elb function accepts: const response = await fetch('/api/products'); const products = await response.json(); - elb('products load', { - count: products.length, - category: currentCategory + elb({ + name: 'products load', + data: { count: products.length, category: currentCategory }, }); return products; @@ -101,29 +104,19 @@ The elb function accepts: { - elb('error occur', { - message: event.message, - source: event.filename, - line: event.lineno + elb({ + name: 'error occur', + data: { + message: event.message, + source: event.filename, + line: event.lineno, + }, }); });`} language="javascript" /> -## Object syntax - -You can also pass events as objects: - - - -## Comparison with HTML Attributes +## Comparison with HTML attributes {#comparison-with-html-attributes} | Use Case | HTML Attributes | JavaScript | | --------------------- | --------------- | ---------- | @@ -146,4 +139,5 @@ JavaScript for dynamic or non-DOM events. - [HTML Attributes](/docs/sources/web/browser/tagging/html-attributes) - Declarative DOM-based tracking - [Tagger](/docs/sources/web/browser/tagger) - Programmatic attribute generation -- [Commands](/docs/sources/web/browser/commands) - Full elb API reference +- [Commands](/docs/sources/web/browser/commands) - `window.elb`, its stub and the + walker commands diff --git a/website/docs/sources/web/dataLayer/index.mdx b/website/docs/sources/web/dataLayer/index.mdx index ec88c546c..15095f9c9 100644 --- a/website/docs/sources/web/dataLayer/index.mdx +++ b/website/docs/sources/web/dataLayer/index.mdx @@ -219,7 +219,7 @@ dataLayer.push({ event: 'purchase', value: 99.99 }); code={`// Old dataLayer.push({ event: 'purchase', value: 99.99 }); // New -elb('order complete', { total: 99.99 });`} +elb({ name: 'order complete', data: { total: 99.99 } });`} language="typescript" /> diff --git a/website/docs/sources/web/session/index.mdx b/website/docs/sources/web/session/index.mdx index dc2675e67..74807ba8e 100644 --- a/website/docs/sources/web/session/index.mdx +++ b/website/docs/sources/web/session/index.mdx @@ -34,7 +34,7 @@ import { sourceSession } from '@walkeros/web-source-session'; const { collector, elb } = await startFlow({ sources: { - browser: sourceBrowser, + browser: { code: sourceBrowser }, session: { code: sourceSession, config: { diff --git a/website/src/pages/demos/_DemoFrame.tsx b/website/src/pages/demos/_DemoFrame.tsx index e28f7d240..90f377ee8 100644 --- a/website/src/pages/demos/_DemoFrame.tsx +++ b/website/src/pages/demos/_DemoFrame.tsx @@ -47,9 +47,10 @@ const LOG_CAP = 200; * demonstration only on `origins`, and does nothing anywhere else. */ const TAG_MODE = { - // The stage copy. Production's file and base - // (https://cdn.walkeros.io/tag-mode/) go in before a release. - file: '4ZKXq-UCwGU5zUPzIulyqfN0ZVOpiYrmGk17PJxEFuoki3qGFbGjyFuW7o3_McBL', + // The stage copy. Before a release only `base` changes, to + // https://cdn.walkeros.io/tag-mode/: production runs the build stage ran, + // so the file keeps its name there. + file: 'SPwvtoe6zx3P8I5OGG4g4CNWmgdQzHHL-E_-TbzmJOiIp80CngpYbz5pKOhSn9FL', base: 'https://stage.cdn.walkeros.io/tag-mode/', origins: ['https://www.walkeros.io', 'https://walkeros.io'], }; From 89d6da37982767c7dd25675b593fd6cee8afba1a Mon Sep 17 00:00:00 2001 From: alexanderkirtzel Date: Wed, 14 Oct 2026 16:09:19 +0200 Subject: [PATCH 2/3] checks --- .github/workflows/website.yml | 13 +++++++++++-- apps/scripts/validate-llms.ts | 4 ++-- package.json | 3 ++- website/docs/sources/web/browser/commands.mdx | 4 +++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 07ac6310b..7fd9e3775 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -65,11 +65,20 @@ jobs: cache: 'npm' - run: npm ci + # The source checks take seconds and need only core's `dev` export, so + # they run before the website build: a docs error fails in about two + # minutes, not after the build. The website build reuses this core build. + - name: Validate docs sources + run: | + npx turbo run build --filter=@walkeros/core + npm run validate:sources + - name: Build website run: npm run build:website - - name: Validate docs and LLM export - run: npm run validate + # Reads the build output, so it runs after the build. + - name: Validate LLM export + run: npm run validate:llms - name: Deploy to production if: diff --git a/apps/scripts/validate-llms.ts b/apps/scripts/validate-llms.ts index d2429e17c..e52d57c8b 100644 --- a/apps/scripts/validate-llms.ts +++ b/apps/scripts/validate-llms.ts @@ -348,8 +348,8 @@ function checkCodeIndentation(): void { const expectations: { pattern: RegExp; description: string }[] = [ { pattern: /^ {2}sources: \{$/, description: ' sources: {' }, { - pattern: /^ {4}browser: sourceBrowser,$/, - description: ' browser: sourceBrowser,', + pattern: /^ {4}browser: \{ code: sourceBrowser \},$/, + description: ' browser: { code: sourceBrowser },', }, { pattern: /^ {2}"browser": \{$/, description: ' "browser": {' }, ]; diff --git a/package.json b/package.json index 83faa686b..b2b69751c 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "validate:skills": "npx tsx apps/scripts/validate-skills-index.ts", "validate:llms": "npx tsx apps/scripts/validate-llms.ts", "validate:coverage": "npx tsx apps/scripts/validate-docs-coverage.ts", - "validate": "npm run validate:links && npm run validate:docs && npm run validate:skills && npm run validate:coverage && npm run validate:llms" + "validate:sources": "npm run validate:links && npm run validate:docs && npm run validate:skills && npm run validate:coverage", + "validate": "npm run validate:sources && npm run validate:llms" }, "devDependencies": { "@changesets/cli": "^2.30.0", diff --git a/website/docs/sources/web/browser/commands.mdx b/website/docs/sources/web/browser/commands.mdx index a7c29fd73..dc6846b38 100644 --- a/website/docs/sources/web/browser/commands.mdx +++ b/website/docs/sources/web/browser/commands.mdx @@ -46,7 +46,9 @@ resolves with the [push result](/docs/getting-started/event-model#push-an-event) Code that can run before the source starts, such as a page script ahead of an `async` walker.js, defines this stub first. It queues every call in -`window.elbLayer`, and the source processes the queue once it starts: +`window.elbLayer`, and the source processes the queue once it starts. A queued +call returns nothing; only calls made after the source installed its `elb` +resolve with the push result: Date: Wed, 14 Oct 2026 17:43:41 +0200 Subject: [PATCH 3/3] example --- .github/workflows/website.yml | 5 +++-- apps/explorer/design/components/bundle.js | 2 +- apps/explorer/design/components/src/viz.jsx | 6 +++--- .../components/viz/__tests__/format.test.ts | 6 ++++-- .../src/design/components/viz/parts/format.ts | 20 +++++++++++-------- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 7fd9e3775..84abab73c 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -111,11 +111,12 @@ jobs: if: github.event_name == 'pull_request' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - BUNNY_PULLZONE_URL: ${{ secrets.BUNNY_WEBSITE_PULLZONE_URL }} PR_NUM: ${{ github.event.pull_request.number }} with: script: | - const previewUrl = `${process.env.BUNNY_PULLZONE_URL}/preview/pr-${process.env.PR_NUM}/`; + // The public host serves the same pull zone as the CDN host the + // deploy purges through. + const previewUrl = `https://www.walkeros.io/preview/pr-${process.env.PR_NUM}/`; const body = `### Preview deployed\n\n${previewUrl}`; // Find existing comment diff --git a/apps/explorer/design/components/bundle.js b/apps/explorer/design/components/bundle.js index bbe698d7c..c2c551646 100644 --- a/apps/explorer/design/components/bundle.js +++ b/apps/explorer/design/components/bundle.js @@ -151,4 +151,4 @@ }; })(); /* ── product visualisations (compiled from components/src/viz.jsx) ── */ -(()=>{const React=window.React,h=React.createElement,{useState,useEffect,useRef}=React,cache=new Map;function st(css){let o=cache.get(css);if(o)return o;o={};for(const decl of css.split(";")){const i=decl.indexOf(":");if(i<0)continue;const k=decl.slice(0,i).trim();if(!k)continue;const key=k.startsWith("--")?k:k.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());o[key]=decl.slice(i+1).trim()}return cache.size<2e3&&cache.set(css,o),o}const V={bg:"var(--viz-bg)",code:"var(--viz-code-bg)",border:"var(--viz-border)",fg:"var(--viz-fg)",fg2:"var(--viz-fg-2)",comment:"var(--viz-comment)",ln:"var(--viz-line-number)",surface:"var(--viz-surface)",primary:"var(--primary)",entity:"var(--event-entity)",action:"var(--event-action)",prop:"var(--event-property)",ctx:"var(--event-context)",glob:"var(--event-globals)",tag:"var(--viz-tag)",str:"var(--viz-string)",num:"var(--viz-number)",punct:"var(--viz-punct)"},SANS="font-family:var(--font-viz)",MONO="font-family:var(--font-viz-mono)",Toks=({list})=>list.map((tk2,i)=>h("span",{key:i,style:st(`color:${tk2.c}; background:${tk2.bg||"transparent"}; border-radius:3px`)},tk2.t)),MCOL={str:V.entity,key:V.glob,num:V.num,fn:V.action,p:V.punct,x:V.fg},MHL="rgba(197,228,120,0.16)";function mtokens(line,hl){const out=[],re=/"[^"]*"|'[^']*'|\d+(?:\.\d+)?|[A-Za-z_]\w*|\s+|./g;let m;for(;m=re.exec(line);){const t=m[0],rest=line.slice(re.lastIndex),isKey=/^\s*:/.test(rest);let c=MCOL.p,bg="transparent";/^["']/.test(t)?(c=isKey?MCOL.key:MCOL.str,hl&&t.slice(1,-1)===hl&&(bg=MHL)):/^\d/.test(t)||t==="true"||t==="false"?c=MCOL.num:/^(gtag|fbq|elb|ttq|track|amplitude|revenue)$/.test(t)?c=MCOL.fn:/^[A-Za-z_]/.test(t)?c=isKey?MCOL.key:MCOL.x:/^\s+$/.test(t)&&(c=MCOL.x),out.push({t,c,bg})}return out}const MD={ga4:{name:"GA4"},meta:{name:"Meta Pixel"},amp:{name:"Amplitude"},tt:{name:"TikTok Pixel"}},MEVENTS=[{entity:"product",action:"view",dests:[{...MD.ga4,mapped:"view_item",out:["gtag('event', 'view_item', {"," currency: 'EUR', value: 420,"," items: [{ item_id: 'ers' }]","})"]},{...MD.meta,mapped:"ViewContent",out:["fbq('track', 'ViewContent', {"," value: 420, currency: 'EUR',"," contents: [{ id: 'ers', quantity: 1 }],"," content_type: 'product'","})"]},{...MD.tt,mapped:"ViewContent",out:["ttq.track('ViewContent', {"," content_id: 'ers', content_type: 'product',"," value: 420, currency: 'EUR'","})"]},{...MD.amp,mapped:"Product Viewed",out:["amplitude.track('Product Viewed', {"," product_id: 'ers', price: 420, currency: 'EUR'","})"]}]},{entity:"lead",action:"submit",dests:[{...MD.ga4,mapped:"generate_lead",out:["gtag('event', 'generate_lead', {"," value: 2500, currency: 'EUR'","})"]},{...MD.meta,mapped:"Lead",out:["fbq('track', 'Lead', {"," value: 2500, currency: 'EUR'","})"]},{...MD.tt,mapped:"Contact",out:["ttq.track('Contact', {"," value: 2500, currency: 'EUR'","})"]},{...MD.amp,mapped:"Lead Submitted",out:["amplitude.track('Lead Submitted', {"," lead_type: 'demo', value: 2500, currency: 'EUR'","})"]}]},{entity:"order",action:"complete",dests:[{...MD.ga4,mapped:"purchase",out:["gtag('event', 'purchase', {"," transaction_id: '0rd3r1d', value: 462,"," currency: 'EUR', items: [\u2026]","})"]},{...MD.meta,mapped:"Purchase",out:["fbq('track', 'Purchase', {"," value: 462, currency: 'EUR',"," contents: [\u2026], content_type: 'product'","})"]},{...MD.tt,mapped:"CompletePayment",out:["ttq.track('CompletePayment', {"," order_id: '0rd3r1d', value: 462, currency: 'EUR',"," contents: [\u2026], content_type: 'product'","})"]},{...MD.amp,mapped:"purchase",ruleText:'"order": { "complete": { "settings": { "revenue": {\u2026} } } }',out:["amplitude.revenue({"," productId: 'ers', price: 420, quantity: 1,"," revenueType: 'purchase', currency: 'EUR'","});","amplitude.track('order complete', { \u2026 })"]}]}],MSHORT={product:"elb('product view', { id: 'ers', price: 420, currency: 'EUR' })",lead:"elb('lead submit', { type: 'demo', value: 2500, currency: 'EUR' })",order:"elb('order complete', { id: '0rd3r1d', value: 462, currency: 'EUR' })"};function DestinationMappingViz(props){var _a;const[ev,setEv]=useState(props.initialEvent||0),E=MEVENTS[ev],entityName=E.entity+" "+E.action,caption=(_a=props.caption)!=null?_a:"40+ destination adapters ship with the project. Removing GA4 or adding a new pixel is a block in this file, not a re-instrumentation project across every page.";return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:#1c2638; border:1px solid #38455e; border-radius:14px; overflow:hidden`)},h("div",{style:st("padding:18px 20px 0; display:flex; flex-direction:column; gap:10px")},h("div",{style:st(`${MONO}; font-size:11px; letter-spacing:0.06em; text-transform:uppercase; color:${V.fg2}`)},"Pick an event"),h("div",{style:st("display:flex; flex-wrap:wrap; gap:8px")},MEVENTS.map((e,i)=>{const a=i===ev;return h("button",{key:i,type:"button",className:"wos-viz-chip",onClick:()=>setEv(i),"aria-pressed":a,style:st(`position:relative; display:flex; overflow:hidden; padding:0; border-radius:7px; border:1px solid ${a?V.primary:"#38455e"}; background:${a?"rgba(1,181,226,0.08)":"transparent"}; ${MONO}; font-size:13px; cursor:pointer; transition:border-color .25s, background .25s`)},h("span",{style:st(`padding:7px 10px; color:${V.fg}; background:rgba(255,255,255,0.04)`)},e.entity),h("span",{style:st(`padding:7px 10px; color:${V.primary}`)},e.action))}))),h("div",{style:st(`margin:16px 20px 0; padding:10px 14px; background:${V.code}; border:1px solid #2f3a4d; border-radius:8px; overflow-x:auto; ${MONO}; font-size:12.5px; line-height:1.6; white-space:pre`)},h(Toks,{list:mtokens(MSHORT[E.entity],entityName)})),h("div",{style:st("padding:18px 20px 20px; display:flex; flex-direction:column; gap:10px")},h("div",{style:st(`${MONO}; font-size:12.5px; font-style:italic; color:${V.comment}`)},"// same event, four destinations, zero app code touched"),h("div",{style:st("display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap:10px")},E.dests.map((d,i)=>{const rule=d.ruleText?d.ruleText:`"${E.entity}": { "${E.action}": { "name": "${d.mapped}" } }`;return h("div",{key:d.name,style:st(`min-width:0; background:${V.code}; border:1px solid #2f3a4d; border-radius:10px; padding:12px 14px; display:flex; flex-direction:column; gap:8px`)},h("span",{style:st("font-size:14px; font-weight:600")},d.name),h("div",{style:st(`display:flex; gap:10px; align-items:baseline; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`)},h("span",{style:st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`)},"mapping"),h("span",{style:st("white-space:pre")},h(Toks,{list:mtokens(rule,d.mapped)}))),h("div",{style:st(`display:flex; gap:10px; align-items:flex-start; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`)},h("span",{style:st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`)},"sends"),h("div",{style:st("min-width:0")},d.out.map((l,j)=>h("div",{key:j,style:st("white-space:pre")},h(Toks,{list:mtokens(l,d.mapped)}))))))}))),caption?h("div",{style:st(`border-top:1px solid #38455e; padding:18px 20px 20px; font-size:15px; line-height:1.6; color:${V.punct}; text-wrap:pretty`)},caption):null)}const ARTICLES=[{kicker:"Tech",cat:"tech",img:"bug",title:"Return of the Bug"},{kicker:"Tech",cat:"tech",title:"The quiet return of RSS"},{kicker:"Food",cat:"food",title:"Why sourdough came back"}],HL_=11500,P={siS:0,siE:500,aS:900,aE:1500,dS:1800,dE:2600,eS:2900,eE:3600,bS:3900,bE:4700,cS:5e3,cE:5800,imp:6200,mS:6900,mE:7700,click:7850,row:8e3,fo:10600,soS:10800,soE:11400},HC={tag:V.tag,attr:V.glob,p:V.punct,str:V.str,txt:V.fg},MAX_ROWS=24,tk=(t,c)=>({t,c,txt:!0,caret:!1}),len=a=>a.reduce((s,x)=>s+x.t.length,0),trunc=(a,n)=>{const o=[];let l=n;for(const x of a){if(l<=0)break;const s=x.t.slice(0,l);o.push({...x,t:s}),l-=s.length}return o},clamp=x=>Math.max(0,Math.min(1,x)),ease=x=>x<.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;function Thumb({art}){const abs={viewBox:"0 0 112 86",preserveAspectRatio:"xMidYMid slice",style:st("position:absolute; inset:0; width:100%; height:100%"),"aria-hidden":!0};return art.img==="bug"?h("svg",abs,h("rect",{width:112,height:86,fill:"#dfe8cf"}),h("ellipse",{cx:56,cy:66,rx:30,ry:6,fill:"#bccaa6"}),h("g",{stroke:"#3b3232",strokeWidth:2.4,strokeLinecap:"round",fill:"none"},["M36 46 L28 42","M35 54 L27 56","M38 61 L31 66","M76 46 L84 42","M77 54 L85 56","M74 61 L81 66","M51 25 Q47 17 42 16","M61 25 Q65 17 70 16"].map((d,i)=>h("path",{key:i,d}))),h("circle",{cx:42,cy:16,r:2,fill:"#3b3232"}),h("circle",{cx:70,cy:16,r:2,fill:"#3b3232"}),h("ellipse",{cx:56,cy:30,rx:10,ry:7.5,fill:"#3b3232"}),h("circle",{cx:52,cy:29,r:1.6,fill:"#ffffff"}),h("circle",{cx:60,cy:29,r:1.6,fill:"#ffffff"}),h("ellipse",{cx:56,cy:50,rx:21,ry:18,fill:"#d9534a"}),h("path",{d:"M56 33 L56 68",stroke:"#3b3232",strokeWidth:2}),h("circle",{cx:47,cy:44,r:3.4,fill:"#3b3232"}),h("circle",{cx:65,cy:44,r:3.4,fill:"#3b3232"}),h("circle",{cx:45,cy:56,r:2.8,fill:"#3b3232"}),h("circle",{cx:67,cy:56,r:2.8,fill:"#3b3232"}),h("path",{d:"M44 38 Q48 35 52 36",stroke:"#ef8a7f",strokeWidth:2,fill:"none",strokeLinecap:"round"})):art.cat==="tech"?h("svg",abs,h("rect",{width:112,height:86,fill:"#334058"}),h("rect",{x:38,y:25,width:36,height:36,rx:8,fill:"#f08a24"}),h("circle",{cx:47,cy:52,r:3.2,fill:"#ffffff"}),h("path",{d:"M45 42 A10 10 0 0 1 57 54",stroke:"#ffffff",strokeWidth:3.5,fill:"none",strokeLinecap:"round"}),h("path",{d:"M45 34.5 A17.5 17.5 0 0 1 64.5 54",stroke:"#ffffff",strokeWidth:3.5,fill:"none",strokeLinecap:"round"})):h("svg",abs,h("rect",{width:112,height:86,fill:"#eadcc6"}),h("ellipse",{cx:56,cy:62,rx:44,ry:10,fill:"#b07d4f"}),h("ellipse",{cx:56,cy:50,rx:31,ry:19,fill:"#c3834a"}),h("ellipse",{cx:56,cy:45,rx:25,ry:13,fill:"#d99d5e"}),h("path",{d:"M40 44 Q56 35 72 44",stroke:"#f3dfb8",strokeWidth:2.2,fill:"none",strokeLinecap:"round"}),h("path",{d:"M44 51 Q56 45 68 51",stroke:"#f3dfb8",strokeWidth:2.2,fill:"none",strokeLinecap:"round"}))}const Pill=({bg,children})=>h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px`)},children);function HeroTaggingViz(props){var _a,_b;const playing=(_a=props.playing)!=null?_a:!0,speed=(_b=props.speed)!=null?_b:1,[t,setT]=useState(props.startAt||0),el=useRef(props.startAt||0),wall0=useRef(Date.now()),cfg=useRef({playing,speed});cfg.current={playing,speed},useEffect(()=>{let last=performance.now(),raf;const loop=now=>{const dt=Math.min(64,now-last);last=now,cfg.current.playing&&(el.current+=dt*cfg.current.speed),setT(el.current),raf=requestAnimationFrame(loop)};return raf=requestAnimationFrame(loop),()=>cancelAnimationFrame(raf)},[]);const k=Math.floor(t/HL_),ph=t-k*HL_,art=ARTICLES[k%3],A=[tk("data-elb",HC.attr),tk("=",HC.p),tk('"article"',HC.str)],B=[tk(" ",HC.p),tk("data-elb-article",HC.attr),tk("=",HC.p),tk('"title:#innerText"',HC.str)],E=[tk(" ",HC.p),tk("data-elb-article",HC.attr),tk("=",HC.p),tk('"category:#innerText"',HC.str)],Cc=[tk(" ",HC.p),tk("data-elbaction",HC.attr),tk("=",HC.p),tk('"click:open"',HC.str)],D=[tk("data-elbaction",HC.attr),tk("=",HC.p),tk('"impression"',HC.str)],typed=(a,s,e)=>Math.round(clamp((ph-s)/(e-s))*len(a)),na=typed(A,P.aS,P.aE),nb=typed(B,P.bS,P.bE),nc=typed(Cc,P.cS,P.cE),nd=typed(D,P.dS,P.dE),ne=typed(E,P.eS,P.eE);let caret=null;ph>=P.aS-400&&ph=P.dS-300&&ph=P.eS-100&&ph=P.bS-100&&ph=P.cS-100&&ph=P.aS&&ph<=P.aE||ph>=P.bS&&ph<=P.bE||ph>=P.cS&&ph<=P.cE||ph>=P.dS&&ph<=P.dE||ph>=P.eS&&ph<=P.eE||ph%900<500?1:0},wc=(a,on)=>on?[...a,car]:a,lines=[{toks:[tk("=P.aS-400&&{toks:[tk(" ",HC.p),...wc(trunc(A,na),caret==="a")],on:!0},ph>=P.dS-300&&{toks:[tk(" ",HC.p),...wc(trunc(D,nd),caret==="d")],on:!0},{toks:[tk(">",HC.tag)]},{toks:[tk(" ",HC.p),tk("",HC.tag)]},{toks:[tk(" ",HC.p),tk("",HC.tag),tk(art.kicker,HC.txt),tk("",HC.tag)],on:ph>=P.eS-100},{toks:[tk(" ",HC.p),tk("",HC.tag),tk(art.title,HC.txt),tk("",HC.tag)],on:ph>=P.bS-100},{toks:[tk(" ",HC.p),tk("",HC.tag),tk("Open \u2192",HC.txt),tk("",HC.tag)],on:ph>=P.cS-100},{toks:[tk("",HC.tag)]}].filter(Boolean).map((l,i)=>({...l,n:i+1})),fade=ph<250?ph/250:ph>P.fo?1-clamp((ph-P.fo)/(HL_-P.fo-150)):1,so=ease(clamp((ph-P.soS)/(P.soE-P.soS))),si=ease(clamp((ph-P.siS)/(P.siE-P.siS))),cardOff=ph>=P.soS?-so*190:(1-si)*190,impOn=ph>=P.dE,entityOn=ph>=P.aE,propsOn=ph>=P.bE,catOn=ph>=P.eE,actionOn=ph>=P.cE,m=ease(clamp((ph-P.mS)/(P.mE-P.mS))),press=ph>=P.click&&ph=P.mS-200&&phP.fo?1-clamp((ph-P.fo)/200):1):0,propGlow="0 0 10px 1px rgba(240,113,127,0.55)",rows=[],evs=[];for(let j=k;j>=0&&evs.length=P.row)&&evs.push({j,at:P.row,action:"open"}),(j=P.imp)&&evs.push({j,at:P.imp,action:"impression"});for(const e of evs.slice(0,MAX_ROWS)){const a=ARTICLES[e.j%3],age=e.j===k?ph-e.at:1e4;rows.push({key:e.j+e.action,n:rows.length+1,name:"article "+e.action,title:a.title,cat:a.kicker.toUpperCase(),bg:`rgba(197,228,120,${(.14*(1-clamp(age/1600))).toFixed(3)})`,op:clamp(age/300),ty:`translateX(${((1-ease(clamp(age/500)))*-80).toFixed(1)}px)`})}const skeletons=["72%","58%","80%","50%","66%","62%"].slice(0,Math.max(0,6-rows.length)).map((w,i)=>({w,n:rows.length+i+1})),grid="display:grid; grid-template-columns:38px 166px minmax(250px,1fr)",cellR="border-right:1px solid "+V.surface;return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border:1px solid #2f3a4d; border-radius:16px; padding:16px; display:flex; flex-wrap:wrap; gap:16px; align-items:stretch`)},h("div",{style:st(`flex:1.2 1 520px; min-width:0; background:${V.code}; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`)},h("div",{style:st("display:flex; align-items:center; gap:14px; padding:12px 16px; border-bottom:1px solid #243044")},h("div",{style:st("display:flex; gap:7px")},[0,1,2].map(i=>h("span",{key:i,style:st(`width:10px; height:10px; border-radius:50%; background:${V.border}`)}))),h("span",{style:st(`${MONO}; font-size:12px; color:${V.primary}`)},"ArticleTeaser.html")),h("div",{style:st(`padding:12px 0 14px; ${MONO}; font-size:13px; line-height:1.95; min-height:268px; opacity:${fade}`)},lines.map(ln=>h("div",{key:ln.n,style:st(`display:flex; background:${ln.on?"rgba(197,228,120,0.07)":"transparent"}`)},h("span",{style:st(`width:3px; flex:none; background:${ln.on?V.entity:"transparent"}`)}),h("span",{style:st(`width:30px; flex:none; text-align:right; padding-right:14px; color:${V.ln}`)},ln.n),h("span",{style:st("min-width:0; white-space:pre-wrap; word-break:break-all; padding-right:16px")},ln.toks.map((x,i)=>x.caret?h("span",{key:i,style:st(`display:inline-block; width:2px; height:1.15em; margin-left:1px; vertical-align:-0.2em; background:${V.primary}; opacity:${x.o}`)}):h("span",{key:i,style:st(`color:${x.c}`)},x.t)))))),h("div",{style:st("flex:1; position:relative; overflow:hidden; border-top:1px solid #243044; background:repeating-linear-gradient(45deg, #172030 0 8px, #1c2535 8px 16px); padding:34px 24px 28px; display:flex; justify-content:center; align-items:center")},h("div",{style:st(`position:relative; width:100%; max-width:430px; display:flex; gap:14px; background:${V.surface}; border:1.5px solid ${entityOn?V.entity:"#38455e"}; border-radius:10px; padding:12px; opacity:${clamp(ph/250)*(1-so)}; transform:translateY(${cardOff.toFixed(1)}px)`)},h("div",{style:st("position:absolute; top:-10px; left:12px; display:flex; gap:6px")},entityOn?h(Pill,{bg:V.entity},"article"):null,impOn?h(Pill,{bg:V.action},"impression"):null),h("div",{style:st("flex:none; width:112px; min-height:86px; border-radius:6px; overflow:hidden; position:relative; background:#2e3a50")},h(Thumb,{art})),h("div",{style:st("flex:1; min-width:0; display:flex; flex-direction:column; gap:4px")},h("span",{style:st(`align-self:flex-start; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; color:${V.fg2}; border-radius:3px; padding:0 3px; margin-left:-3px; outline:1.5px solid ${catOn?V.prop:"transparent"}; box-shadow:${catOn?propGlow:"none"}`)},art.kicker),h("span",{style:st("font-size:15px; font-weight:600; line-height:1.3; text-wrap:pretty")},h("span",{style:st(`border-radius:3px; outline:1.5px solid ${propsOn?V.prop:"transparent"}; outline-offset:1px; box-shadow:${propsOn?propGlow:"none"}`)},art.title)),h("div",{style:st("margin-top:auto; padding-top:6px; display:flex; align-items:center; gap:8px")},h("span",{style:st(`position:relative; font-size:13px; font-weight:500; color:${V.primary}; padding:1px 5px; margin-left:-5px; border-radius:5px; outline:1.5px solid ${actionOn?V.action:"transparent"}`)},"Open \u2192",h("span",{style:st(`position:absolute; left:${(22+(1-m)*190).toFixed(1)}px; top:${(11-(1-m)*80).toFixed(1)}px; width:22px; height:22px; margin:-11px 0 0 -11px; pointer-events:none; opacity:${curOp}`)},h("span",{style:st(`position:absolute; inset:0; border-radius:50%; border:2px solid ${V.entity}; opacity:${ph>=P.click?((1-rp)*.9).toFixed(3):0}; transform:scale(${(1+rp*1.8).toFixed(3)})`)}),h("span",{style:st(`position:absolute; inset:0; border-radius:50%; background:rgba(255,255,255,0.22); border:2px solid rgba(255,255,255,0.9); box-shadow:0 4px 14px rgba(0,0,0,0.4); transform:scale(${press?.8:1})`)})))))))),h("div",{style:st(`flex:1 1 400px; min-width:0; background:#1e2839; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`)},h("div",{style:st(`display:flex; justify-content:space-between; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid ${V.surface}; ${MONO}; font-size:12px`)},h("span",{style:st(`color:${V.fg2}`)},h("span",{style:st(`color:${V.action}`)},"SELECT")," * ",h("span",{style:st(`color:${V.action}`)},"FROM")," ",h("span",{style:st(`color:${V.fg}`)},"walkerOS.events")),h("span",{style:st(`display:flex; align-items:center; gap:7px; color:${V.entity}`)},h("span",{style:st(`width:7px; height:7px; border-radius:50%; background:${V.entity}`)}),"live")),h("div",{style:st("flex:1; position:relative; min-height:300px")},h("div",{style:st(`position:absolute; inset:0; overflow-x:auto; overflow-y:hidden; ${MONO}; font-size:12px`)},h("div",{style:st("min-width:454px")},h("div",{style:st(`${grid}; background:#243044; border-bottom:1px solid ${V.border}`)},h("span",{style:st(`padding:8px 10px; ${cellR}; color:${V.comment}; font-size:11px; text-align:right`)},"Row"),h("span",{style:st(`padding:8px 10px; ${cellR}; display:flex; flex-direction:column; gap:1px`)},h("span",{style:st(`color:${V.fg}`)},"name"),h("span",{style:st(`color:${V.comment}; font-size:10px`)},"STRING")),h("span",{style:st("padding:8px 10px; display:flex; flex-direction:column; gap:1px")},h("span",{style:st(`color:${V.fg}`)},"data"),h("span",{style:st(`color:${V.comment}; font-size:10px`)},"JSON"))),rows.map(r=>h("div",{key:r.key,style:st(`${grid}; border-bottom:1px solid ${V.surface}; background:${r.bg}; opacity:${r.op}; transform:${r.ty}; line-height:1.4`)},h("span",{style:st(`padding:10px; ${cellR}; color:${V.comment}; text-align:right`)},r.n),h("span",{style:st(`padding:10px; ${cellR}; color:${V.fg}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`)},r.name),h("span",{style:st(`padding:10px; color:${V.punct}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`)},'{"',h("span",{style:st(`color:${V.glob}`)},"title"),'":"',h("span",{style:st(`color:${V.str}`)},r.title),'","',h("span",{style:st(`color:${V.glob}`)},"category"),'":"',h("span",{style:st(`color:${V.str}`)},r.cat),'"}'))),skeletons.map(s=>h("div",{key:"s"+s.n,style:st(`${grid}; border-bottom:1px solid ${V.surface}; line-height:1.4`)},h("span",{style:st(`padding:10px; ${cellR}; color:${V.ln}; text-align:right`)},s.n),h("span",{style:st(`padding:10px; ${cellR}; display:flex; align-items:center`)},h("span",{style:st(`height:8px; width:75%; border-radius:4px; background:${V.surface}`)})),h("span",{style:st("padding:10px; display:flex; align-items:center")},h("span",{style:st(`height:8px; width:${s.w}; border-radius:4px; background:#253145`)})))))))))}const AC={kw:V.action,tag:V.tag,at:V.glob,p:V.punct,s:V.str,x:V.fg},TY={entity:[V.entity,"rgba(197,228,120,"],action:[V.action,"rgba(199,146,234,"],prop:[V.prop,"rgba(240,113,127,"],ctx:[V.ctx,"rgba(242,166,90,"],glob:[V.glob,"rgba(127,196,234,"]},at=(t,c,hl)=>({t,c,h:!!hl}),F=(step,name,kind)=>({step,file:!0,name,kind}),L=(step,toks,fx,type)=>({step,toks,fx,type});function buildItems(){const it=[F(1,"Link.tsx","atom"),L(1,[at("export const",AC.kw),at(" Link ",AC.x),at("= ({ href, children }) => (",AC.p)]),L(1,[at(" "),at("",AC.tag)],"action","action"),L(1,[at(" "),at("{children}",AC.p)]),L(1,[at(" "),at("",AC.tag)]),L(1,[at(");",AC.p)]),F(2,"ArticleTeaser.tsx","molecule"),L(2,[at("export const",AC.kw),at(" ArticleTeaser ",AC.x),at("= ({",AC.p)]),L(2,[at(" title, category, image, url, position,",AC.p)]),L(2,[at("}) => (",AC.p)]),L(2,[at(" "),at("",AC.tag)],"pos","prop"),L(2,[at(" "),at("",AC.tag)],"img"),L(2,[at(" "),at("",AC.tag),at("{category}",AC.p),at("",AC.tag)],"cat","prop"),L(2,[at(" "),at("",AC.tag),at("{title}",AC.p),at("",AC.tag)],"title","prop"),L(2,[at(" "),at("",AC.tag),at("Open \u2192",AC.x),at("",AC.tag)]),L(2,[at(" "),at("",AC.tag)]),L(2,[at(");",AC.p)])];return[[3,"HomeFeed.tsx","homefeed","stories","home"],[4,"RelatedStories.tsx","related","related","related"]].forEach(([s,f,ctx,arr,fx])=>it.push(F(s,f,"organism"),L(s,[at("",AC.tag)],fx,"ctx"),L(s,[at(" {"+arr+".map((s, i) => ",AC.p),at("",AC.tag),at(")}",AC.p)]),L(s,[at("",AC.tag)]))),[["HomePage.tsx","homepage","HomeFeed","gHome"],["ArticlePage.tsx","article","RelatedStories","gArt"]].forEach(([file,pt,org,fx])=>it.push(F(5,file,"template"),L(5,[at("",AC.tag)],fx,"glob"),L(5,[at(" "),at("<"+org+" />",AC.tag)]),L(5,[at("",AC.tag)]))),it}const ITEMS=buildItems(),HOME=[{kicker:"Tech",title:"Return of the Bug"},{kicker:"Tech",title:"The quiet return of RSS"},{kicker:"Food",title:"Why sourdough came back"}],RELATED=[{kicker:"Tech",title:"The quiet return of RSS"},{kicker:"Science",title:"A year on the ice shelf"},{kicker:"Culture",title:"Small venues, big nights"}],STEPS=[[1,"Atom"],[2,"Molecule"],[3,"HomeFeed"],[4,"RelatedStories"],[5,"Pages"]],CAPTIONS={1:"Atom. Link carries the click:open action wherever it is rendered.",2:"Molecule. ArticleTeaser makes each card an article entity and reads category and title from its elements.",3:"Organism. HomeFeed wraps three teasers and adds list:homefeed context to their events.",4:"Organism. RelatedStories reuses the same ArticleTeaser and adds list:related context.",5:"Pages. Each page template sets pagetype as a global, which is added to every event on that page."},aglow=rgba=>"0 0 10px 1px "+rgba+"0.5)";class ArticleTeaserTracking extends React.Component{constructor(p){super(p),this.state={n:p.initialStep?this.stepEnd(p.initialStep):0},this.codeRef=React.createRef()}get auto(){var _a;return(_a=this.props.autoplay)!=null?_a:!0}get speed(){var _a;return(_a=this.props.speed)!=null?_a:1}stepEnd(k){let last=ITEMS.findIndex(x=>x.step===k);for(;ITEMS[last+1]&&ITEMS[last+1].step===k;)last++;return last+1}componentDidMount(){const go=()=>this.schedule(800),el=this.codeRef.current;if(!el||!window.IntersectionObserver)return go();this.io=new IntersectionObserver(es=>{es.some(e=>e.isIntersecting)&&(this.io.disconnect(),go())},{threshold:.35}),this.io.observe(el)}componentWillUnmount(){clearTimeout(this.tm),clearTimeout(this.st),this.io&&this.io.disconnect()}componentDidUpdate(pp){pp.autoplay!==this.props.autoplay&&this.auto&&this.schedule(500)}scrollToCurrent(n){clearTimeout(this.st),this.st=setTimeout(()=>{const el=this.codeRef.current;if(!el)return;const tgt=el.querySelector('[data-ln="'+(n-1)+'"]');if(!tgt){el.scrollTo({top:0,behavior:"smooth"});return}const y=tgt.getBoundingClientRect().top-el.getBoundingClientRect().top+el.scrollTop;el.scrollTo({top:Math.max(0,y-el.clientHeight/2),behavior:"smooth"})},400)}setN(n){this.setState({n}),this.scrollToCurrent(n)}schedule(ms){clearTimeout(this.tm),this.tm=setTimeout(()=>this.advance(),ms/this.speed)}advance(){if(!this.auto)return;const n=this.state.n;if(n>=ITEMS.length)return;const s=ITEMS[n].step;if(ITEMS[n].file&&(n===0||ITEMS[n-1].step!==s)){this.setN(n+1),this.schedule(1300);return}let j=n;for(;jx.step===k);this.auto?(this.setN(first),this.schedule(250)):this.setN(this.stepEnd(k))}render(){const n=Math.min(this.state.n,ITEMS.length),visStep=n?ITEMS[n-1].step:0,cur=Math.max(1,visStep),seen=new Set(ITEMS.slice(0,n).map(x=>x.fx).filter(Boolean)),f={};["action","entity","pos","img","cat","title","home","related","gHome","gArt"].forEach(x=>f[x]=seen.has(x));let num=0,firstFile=!0;const lines=ITEMS.map((it,i)=>{const vis=it.step<=visStep,on=ih("span",{key:j,style:st(`color:${k.c||AC.p}; background:${k.h&&ty&&on?ty[1]+"0.16)":"transparent"}; border-radius:3px; transition:background .35s`)},k.t)))))}),card=(c,i)=>h("div",{key:i,style:st(`position:relative; min-height:224px; border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:8px; border:1.5px ${f.entity?"solid":"dashed"} ${f.entity?V.entity:"#2b374c"}; background:${f.entity?V.surface:"transparent"}; transition:all .45s ease`)},h("span",{style:st("position:absolute; top:-10px; left:10px; display:flex; gap:4px")},h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${V.entity}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${f.entity?1:0}; transition:opacity .4s`)},"article"),h("span",{style:st(`padding:0 6px; border-radius:4px; border:1.5px solid ${V.prop}; background:#1a2232; color:${V.prop}; ${MONO}; font-size:10.5px; line-height:16px; opacity:${f.pos?1:0}; transition:opacity .4s`)},i+1)),h("div",{style:st(`height:86px; flex:none; border-radius:6px; overflow:hidden; position:relative; background:repeating-linear-gradient(45deg, #2e3a50 0 6px, #323f56 6px 12px); display:flex; align-items:center; justify-content:center; ${MONO}; font-size:10.5px; color:${V.comment}; opacity:${f.img?1:0}; transition:opacity .45s`)},"photo"),h("span",{style:st(`align-self:flex-start; padding:1px 6px; border-radius:4px; background:#334058; color:#c3c9d9; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; opacity:${f.cat?1:0}; outline:1.5px solid ${f.cat?V.prop:"transparent"}; box-shadow:${f.cat?aglow(TY.prop[1]):"none"}; transition:all .45s`)},c.kicker),h("span",{style:st(`align-self:flex-start; font-size:14px; font-weight:600; line-height:1.3; text-wrap:pretty; border-radius:3px; outline:1.5px solid ${f.title?V.prop:"transparent"}; outline-offset:2px; box-shadow:${f.title?aglow(TY.prop[1]):"none"}; opacity:${f.title?1:0}; transition:all .45s`)},c.title),h("span",{style:st(`margin-top:auto; align-self:flex-start; padding:2px 6px; margin-left:-6px; border-radius:4px; font-size:13px; font-weight:500; color:${V.primary}; outline:1.5px solid ${f.action?V.action:"transparent"}; box-shadow:${f.action?aglow(TY.action[1]):"none"}; opacity:${f.action?1:0}; transition:all .45s`)},"Open \u2192")),pill=(bg,txt,op)=>h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${op}; transition:opacity .5s`)},txt),group=(on,label,listOn,header,ctx,cards)=>h("div",{style:st(`position:relative; border-radius:16px; padding:12px; border:1.5px dashed ${on?V.glob:"transparent"}; background:${on?"rgba(127,196,234,0.04)":"transparent"}; transition:all .5s`)},h("span",{style:st("position:absolute; top:-10px; left:14px")},pill(V.glob,label,on?1:0)),h("div",{style:st(`border-radius:12px; padding:14px; display:flex; flex-direction:column; gap:16px; border:1.5px solid ${listOn?V.ctx:"#243044"}; background:${listOn?"rgba(242,166,90,0.05)":"transparent"}; transition:all .5s`)},h("div",{style:st(`display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; min-height:19px; opacity:${listOn?1:0}; transition:opacity .5s`)},header,pill(V.ctx,ctx,1)),h("div",{style:st("display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:12px")},cards.map(card)))),legend=[["entity",V.entity],["action",V.action],["property",V.prop],["context",V.ctx],["globals",V.glob]];return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border-radius:16px; padding:24px; display:flex; flex-direction:column; gap:18px`)},h("div",{style:st("display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px 20px")},h("div",{style:st("display:flex; flex-wrap:wrap; gap:8px")},STEPS.map(([k,label])=>{const a=k===cur,done=kthis.jump(k),"aria-pressed":a,style:st(`display:flex; align-items:center; gap:8px; padding:6px 12px 6px 6px; border-radius:999px; border:1px solid ${a?V.entity:V.border}; background:${a?"rgba(197,228,120,0.10)":"transparent"}; color:${a?V.fg:V.fg2}; ${SANS}; font-size:13px; font-weight:500; cursor:pointer; transition:all .3s`)},h("span",{style:st(`width:20px; height:20px; border-radius:50%; background:${a?V.entity:done?V.comment:V.border}; color:#1a2232; font-weight:600; font-size:11px; line-height:20px; text-align:center; transition:all .3s`)},k),h("span",null,label))})),h("div",{style:st(`display:flex; flex-wrap:wrap; gap:14px; ${MONO}; font-size:11px; color:${V.fg2}`)},legend.map(([n2,c])=>h("span",{key:n2,style:st("display:flex; align-items:center; gap:6px")},h("span",{style:st(`width:9px; height:9px; border-radius:2px; background:${c}`)}),n2)))),h("div",{style:st("font-size:15px; line-height:1.5; color:#c3c9d9; min-height:23px; text-wrap:pretty")},CAPTIONS[cur]),h("div",{style:st("display:flex; flex-wrap:wrap; gap:16px; align-items:stretch")},h("div",{style:st(`flex:1 1 380px; min-width:0; align-self:flex-start; position:sticky; top:16px; background:${V.code}; border:1px solid ${V.border}; border-radius:12px; overflow:hidden; padding:4px 0 10px`)},h("div",{ref:this.codeRef,style:st(`position:relative; overflow:auto; max-height:min(640px, calc(100vh - 48px)); ${MONO}; font-size:12px`)},lines)),h("div",{style:st("flex:1.1 1 420px; min-width:0; display:flex; flex-direction:column; gap:24px; padding-top:10px; align-self:flex-start")},group(f.gHome,"pagetype:homepage",f.home,h("span",{style:st(`${MONO}; font-size:12px; color:${V.fg2}`)}," \xB7 homepage"),"list:homefeed",HOME),group(f.gArt,"pagetype:article",f.related,h("span",{style:st("display:flex; align-items:center; gap:10px")},h("span",{style:st(`${MONO}; font-size:12px; color:${V.fg2}`)}," \xB7 article page"),h("span",{style:st("font-size:14px; font-weight:600")},"Related stories")),"list:related",RELATED))))}}Object.assign(window.WalkerOS=window.WalkerOS||{},{HeroTaggingViz,ArticleTeaserTracking,DestinationMappingViz});})(); +(()=>{const React=window.React,h=React.createElement,{useState,useEffect,useRef}=React,cache=new Map;function st(css){let o=cache.get(css);if(o)return o;o={};for(const decl of css.split(";")){const i=decl.indexOf(":");if(i<0)continue;const k=decl.slice(0,i).trim();if(!k)continue;const key=k.startsWith("--")?k:k.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());o[key]=decl.slice(i+1).trim()}return cache.size<2e3&&cache.set(css,o),o}const V={bg:"var(--viz-bg)",code:"var(--viz-code-bg)",border:"var(--viz-border)",fg:"var(--viz-fg)",fg2:"var(--viz-fg-2)",comment:"var(--viz-comment)",ln:"var(--viz-line-number)",surface:"var(--viz-surface)",primary:"var(--primary)",entity:"var(--event-entity)",action:"var(--event-action)",prop:"var(--event-property)",ctx:"var(--event-context)",glob:"var(--event-globals)",tag:"var(--viz-tag)",str:"var(--viz-string)",num:"var(--viz-number)",punct:"var(--viz-punct)"},SANS="font-family:var(--font-viz)",MONO="font-family:var(--font-viz-mono)",Toks=({list})=>list.map((tk2,i)=>h("span",{key:i,style:st(`color:${tk2.c}; background:${tk2.bg||"transparent"}; border-radius:3px`)},tk2.t)),MCOL={str:V.entity,key:V.glob,num:V.num,fn:V.action,p:V.punct,x:V.fg},MHL="rgba(197,228,120,0.16)";function mtokens(line,hl){const out=[],re=/"[^"]*"|'[^']*'|\d+(?:\.\d+)?|[A-Za-z_]\w*|\s+|./g;let m;for(;m=re.exec(line);){const t=m[0],rest=line.slice(re.lastIndex),isKey=/^\s*:/.test(rest);let c=MCOL.p,bg="transparent";/^["']/.test(t)?(c=isKey?MCOL.key:MCOL.str,hl&&t.slice(1,-1)===hl&&(bg=MHL)):/^\d/.test(t)||t==="true"||t==="false"?c=MCOL.num:/^(gtag|fbq|elb|ttq|track|amplitude|revenue)$/.test(t)?c=MCOL.fn:/^[A-Za-z_]/.test(t)?c=isKey?MCOL.key:MCOL.x:/^\s+$/.test(t)&&(c=MCOL.x),out.push({t,c,bg})}return out}const MD={ga4:{name:"GA4"},meta:{name:"Meta Pixel"},amp:{name:"Amplitude"},tt:{name:"TikTok Pixel"}},MEVENTS=[{entity:"product",action:"view",dests:[{...MD.ga4,mapped:"view_item",out:["gtag('event', 'view_item', {"," currency: 'EUR', value: 420,"," items: [{ item_id: 'ers' }]","})"]},{...MD.meta,mapped:"ViewContent",out:["fbq('track', 'ViewContent', {"," value: 420, currency: 'EUR',"," contents: [{ id: 'ers', quantity: 1 }],"," content_type: 'product'","})"]},{...MD.tt,mapped:"ViewContent",out:["ttq.track('ViewContent', {"," content_id: 'ers', content_type: 'product',"," value: 420, currency: 'EUR'","})"]},{...MD.amp,mapped:"Product Viewed",out:["amplitude.track('Product Viewed', {"," product_id: 'ers', price: 420, currency: 'EUR'","})"]}]},{entity:"lead",action:"submit",dests:[{...MD.ga4,mapped:"generate_lead",out:["gtag('event', 'generate_lead', {"," value: 2500, currency: 'EUR'","})"]},{...MD.meta,mapped:"Lead",out:["fbq('track', 'Lead', {"," value: 2500, currency: 'EUR'","})"]},{...MD.tt,mapped:"Contact",out:["ttq.track('Contact', {"," value: 2500, currency: 'EUR'","})"]},{...MD.amp,mapped:"Lead Submitted",out:["amplitude.track('Lead Submitted', {"," lead_type: 'demo', value: 2500, currency: 'EUR'","})"]}]},{entity:"order",action:"complete",dests:[{...MD.ga4,mapped:"purchase",out:["gtag('event', 'purchase', {"," transaction_id: '0rd3r1d', value: 462,"," currency: 'EUR', items: [\u2026]","})"]},{...MD.meta,mapped:"Purchase",out:["fbq('track', 'Purchase', {"," value: 462, currency: 'EUR',"," contents: [\u2026], content_type: 'product'","})"]},{...MD.tt,mapped:"CompletePayment",out:["ttq.track('CompletePayment', {"," order_id: '0rd3r1d', value: 462, currency: 'EUR',"," contents: [\u2026], content_type: 'product'","})"]},{...MD.amp,mapped:"purchase",ruleText:'"order": { "complete": { "settings": { "revenue": {\u2026} } } }',out:["amplitude.revenue({"," productId: 'ers', price: 420, quantity: 1,"," revenueType: 'purchase', currency: 'EUR'","});","amplitude.track('order complete', { \u2026 })"]}]}],MSHORT={product:"elb({ name: 'product view', data: { id: 'ers', price: 420, currency: 'EUR' } })",lead:"elb({ name: 'lead submit', data: { type: 'demo', value: 2500, currency: 'EUR' } })",order:"elb({ name: 'order complete', data: { id: '0rd3r1d', value: 462, currency: 'EUR' } })"};function DestinationMappingViz(props){var _a;const[ev,setEv]=useState(props.initialEvent||0),E=MEVENTS[ev],entityName=E.entity+" "+E.action,caption=(_a=props.caption)!=null?_a:"40+ destination adapters ship with the project. Removing GA4 or adding a new pixel is a block in this file, not a re-instrumentation project across every page.";return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:#1c2638; border:1px solid #38455e; border-radius:14px; overflow:hidden`)},h("div",{style:st("padding:18px 20px 0; display:flex; flex-direction:column; gap:10px")},h("div",{style:st(`${MONO}; font-size:11px; letter-spacing:0.06em; text-transform:uppercase; color:${V.fg2}`)},"Pick an event"),h("div",{style:st("display:flex; flex-wrap:wrap; gap:8px")},MEVENTS.map((e,i)=>{const a=i===ev;return h("button",{key:i,type:"button",className:"wos-viz-chip",onClick:()=>setEv(i),"aria-pressed":a,style:st(`position:relative; display:flex; overflow:hidden; padding:0; border-radius:7px; border:1px solid ${a?V.primary:"#38455e"}; background:${a?"rgba(1,181,226,0.08)":"transparent"}; ${MONO}; font-size:13px; cursor:pointer; transition:border-color .25s, background .25s`)},h("span",{style:st(`padding:7px 10px; color:${V.fg}; background:rgba(255,255,255,0.04)`)},e.entity),h("span",{style:st(`padding:7px 10px; color:${V.primary}`)},e.action))}))),h("div",{style:st(`margin:16px 20px 0; padding:10px 14px; background:${V.code}; border:1px solid #2f3a4d; border-radius:8px; overflow-x:auto; ${MONO}; font-size:12.5px; line-height:1.6; white-space:pre`)},h(Toks,{list:mtokens(MSHORT[E.entity],entityName)})),h("div",{style:st("padding:18px 20px 20px; display:flex; flex-direction:column; gap:10px")},h("div",{style:st(`${MONO}; font-size:12.5px; font-style:italic; color:${V.comment}`)},"// same event, four destinations, zero app code touched"),h("div",{style:st("display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap:10px")},E.dests.map((d,i)=>{const rule=d.ruleText?d.ruleText:`"${E.entity}": { "${E.action}": { "name": "${d.mapped}" } }`;return h("div",{key:d.name,style:st(`min-width:0; background:${V.code}; border:1px solid #2f3a4d; border-radius:10px; padding:12px 14px; display:flex; flex-direction:column; gap:8px`)},h("span",{style:st("font-size:14px; font-weight:600")},d.name),h("div",{style:st(`display:flex; gap:10px; align-items:baseline; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`)},h("span",{style:st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`)},"mapping"),h("span",{style:st("white-space:pre")},h(Toks,{list:mtokens(rule,d.mapped)}))),h("div",{style:st(`display:flex; gap:10px; align-items:flex-start; overflow-x:auto; ${MONO}; font-size:12px; line-height:1.7`)},h("span",{style:st(`flex:none; width:58px; font-size:10.5px; color:${V.comment}`)},"sends"),h("div",{style:st("min-width:0")},d.out.map((l,j)=>h("div",{key:j,style:st("white-space:pre")},h(Toks,{list:mtokens(l,d.mapped)}))))))}))),caption?h("div",{style:st(`border-top:1px solid #38455e; padding:18px 20px 20px; font-size:15px; line-height:1.6; color:${V.punct}; text-wrap:pretty`)},caption):null)}const ARTICLES=[{kicker:"Tech",cat:"tech",img:"bug",title:"Return of the Bug"},{kicker:"Tech",cat:"tech",title:"The quiet return of RSS"},{kicker:"Food",cat:"food",title:"Why sourdough came back"}],HL_=11500,P={siS:0,siE:500,aS:900,aE:1500,dS:1800,dE:2600,eS:2900,eE:3600,bS:3900,bE:4700,cS:5e3,cE:5800,imp:6200,mS:6900,mE:7700,click:7850,row:8e3,fo:10600,soS:10800,soE:11400},HC={tag:V.tag,attr:V.glob,p:V.punct,str:V.str,txt:V.fg},MAX_ROWS=24,tk=(t,c)=>({t,c,txt:!0,caret:!1}),len=a=>a.reduce((s,x)=>s+x.t.length,0),trunc=(a,n)=>{const o=[];let l=n;for(const x of a){if(l<=0)break;const s=x.t.slice(0,l);o.push({...x,t:s}),l-=s.length}return o},clamp=x=>Math.max(0,Math.min(1,x)),ease=x=>x<.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;function Thumb({art}){const abs={viewBox:"0 0 112 86",preserveAspectRatio:"xMidYMid slice",style:st("position:absolute; inset:0; width:100%; height:100%"),"aria-hidden":!0};return art.img==="bug"?h("svg",abs,h("rect",{width:112,height:86,fill:"#dfe8cf"}),h("ellipse",{cx:56,cy:66,rx:30,ry:6,fill:"#bccaa6"}),h("g",{stroke:"#3b3232",strokeWidth:2.4,strokeLinecap:"round",fill:"none"},["M36 46 L28 42","M35 54 L27 56","M38 61 L31 66","M76 46 L84 42","M77 54 L85 56","M74 61 L81 66","M51 25 Q47 17 42 16","M61 25 Q65 17 70 16"].map((d,i)=>h("path",{key:i,d}))),h("circle",{cx:42,cy:16,r:2,fill:"#3b3232"}),h("circle",{cx:70,cy:16,r:2,fill:"#3b3232"}),h("ellipse",{cx:56,cy:30,rx:10,ry:7.5,fill:"#3b3232"}),h("circle",{cx:52,cy:29,r:1.6,fill:"#ffffff"}),h("circle",{cx:60,cy:29,r:1.6,fill:"#ffffff"}),h("ellipse",{cx:56,cy:50,rx:21,ry:18,fill:"#d9534a"}),h("path",{d:"M56 33 L56 68",stroke:"#3b3232",strokeWidth:2}),h("circle",{cx:47,cy:44,r:3.4,fill:"#3b3232"}),h("circle",{cx:65,cy:44,r:3.4,fill:"#3b3232"}),h("circle",{cx:45,cy:56,r:2.8,fill:"#3b3232"}),h("circle",{cx:67,cy:56,r:2.8,fill:"#3b3232"}),h("path",{d:"M44 38 Q48 35 52 36",stroke:"#ef8a7f",strokeWidth:2,fill:"none",strokeLinecap:"round"})):art.cat==="tech"?h("svg",abs,h("rect",{width:112,height:86,fill:"#334058"}),h("rect",{x:38,y:25,width:36,height:36,rx:8,fill:"#f08a24"}),h("circle",{cx:47,cy:52,r:3.2,fill:"#ffffff"}),h("path",{d:"M45 42 A10 10 0 0 1 57 54",stroke:"#ffffff",strokeWidth:3.5,fill:"none",strokeLinecap:"round"}),h("path",{d:"M45 34.5 A17.5 17.5 0 0 1 64.5 54",stroke:"#ffffff",strokeWidth:3.5,fill:"none",strokeLinecap:"round"})):h("svg",abs,h("rect",{width:112,height:86,fill:"#eadcc6"}),h("ellipse",{cx:56,cy:62,rx:44,ry:10,fill:"#b07d4f"}),h("ellipse",{cx:56,cy:50,rx:31,ry:19,fill:"#c3834a"}),h("ellipse",{cx:56,cy:45,rx:25,ry:13,fill:"#d99d5e"}),h("path",{d:"M40 44 Q56 35 72 44",stroke:"#f3dfb8",strokeWidth:2.2,fill:"none",strokeLinecap:"round"}),h("path",{d:"M44 51 Q56 45 68 51",stroke:"#f3dfb8",strokeWidth:2.2,fill:"none",strokeLinecap:"round"}))}const Pill=({bg,children})=>h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px`)},children);function HeroTaggingViz(props){var _a,_b;const playing=(_a=props.playing)!=null?_a:!0,speed=(_b=props.speed)!=null?_b:1,[t,setT]=useState(props.startAt||0),el=useRef(props.startAt||0),wall0=useRef(Date.now()),cfg=useRef({playing,speed});cfg.current={playing,speed},useEffect(()=>{let last=performance.now(),raf;const loop=now=>{const dt=Math.min(64,now-last);last=now,cfg.current.playing&&(el.current+=dt*cfg.current.speed),setT(el.current),raf=requestAnimationFrame(loop)};return raf=requestAnimationFrame(loop),()=>cancelAnimationFrame(raf)},[]);const k=Math.floor(t/HL_),ph=t-k*HL_,art=ARTICLES[k%3],A=[tk("data-elb",HC.attr),tk("=",HC.p),tk('"article"',HC.str)],B=[tk(" ",HC.p),tk("data-elb-article",HC.attr),tk("=",HC.p),tk('"title:#innerText"',HC.str)],E=[tk(" ",HC.p),tk("data-elb-article",HC.attr),tk("=",HC.p),tk('"category:#innerText"',HC.str)],Cc=[tk(" ",HC.p),tk("data-elbaction",HC.attr),tk("=",HC.p),tk('"click:open"',HC.str)],D=[tk("data-elbaction",HC.attr),tk("=",HC.p),tk('"impression"',HC.str)],typed=(a,s,e)=>Math.round(clamp((ph-s)/(e-s))*len(a)),na=typed(A,P.aS,P.aE),nb=typed(B,P.bS,P.bE),nc=typed(Cc,P.cS,P.cE),nd=typed(D,P.dS,P.dE),ne=typed(E,P.eS,P.eE);let caret=null;ph>=P.aS-400&&ph=P.dS-300&&ph=P.eS-100&&ph=P.bS-100&&ph=P.cS-100&&ph=P.aS&&ph<=P.aE||ph>=P.bS&&ph<=P.bE||ph>=P.cS&&ph<=P.cE||ph>=P.dS&&ph<=P.dE||ph>=P.eS&&ph<=P.eE||ph%900<500?1:0},wc=(a,on)=>on?[...a,car]:a,lines=[{toks:[tk("=P.aS-400&&{toks:[tk(" ",HC.p),...wc(trunc(A,na),caret==="a")],on:!0},ph>=P.dS-300&&{toks:[tk(" ",HC.p),...wc(trunc(D,nd),caret==="d")],on:!0},{toks:[tk(">",HC.tag)]},{toks:[tk(" ",HC.p),tk("",HC.tag)]},{toks:[tk(" ",HC.p),tk("",HC.tag),tk(art.kicker,HC.txt),tk("",HC.tag)],on:ph>=P.eS-100},{toks:[tk(" ",HC.p),tk("",HC.tag),tk(art.title,HC.txt),tk("",HC.tag)],on:ph>=P.bS-100},{toks:[tk(" ",HC.p),tk("",HC.tag),tk("Open \u2192",HC.txt),tk("",HC.tag)],on:ph>=P.cS-100},{toks:[tk("",HC.tag)]}].filter(Boolean).map((l,i)=>({...l,n:i+1})),fade=ph<250?ph/250:ph>P.fo?1-clamp((ph-P.fo)/(HL_-P.fo-150)):1,so=ease(clamp((ph-P.soS)/(P.soE-P.soS))),si=ease(clamp((ph-P.siS)/(P.siE-P.siS))),cardOff=ph>=P.soS?-so*190:(1-si)*190,impOn=ph>=P.dE,entityOn=ph>=P.aE,propsOn=ph>=P.bE,catOn=ph>=P.eE,actionOn=ph>=P.cE,m=ease(clamp((ph-P.mS)/(P.mE-P.mS))),press=ph>=P.click&&ph=P.mS-200&&phP.fo?1-clamp((ph-P.fo)/200):1):0,propGlow="0 0 10px 1px rgba(240,113,127,0.55)",rows=[],evs=[];for(let j=k;j>=0&&evs.length=P.row)&&evs.push({j,at:P.row,action:"open"}),(j=P.imp)&&evs.push({j,at:P.imp,action:"impression"});for(const e of evs.slice(0,MAX_ROWS)){const a=ARTICLES[e.j%3],age=e.j===k?ph-e.at:1e4;rows.push({key:e.j+e.action,n:rows.length+1,name:"article "+e.action,title:a.title,cat:a.kicker.toUpperCase(),bg:`rgba(197,228,120,${(.14*(1-clamp(age/1600))).toFixed(3)})`,op:clamp(age/300),ty:`translateX(${((1-ease(clamp(age/500)))*-80).toFixed(1)}px)`})}const skeletons=["72%","58%","80%","50%","66%","62%"].slice(0,Math.max(0,6-rows.length)).map((w,i)=>({w,n:rows.length+i+1})),grid="display:grid; grid-template-columns:38px 166px minmax(250px,1fr)",cellR="border-right:1px solid "+V.surface;return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border:1px solid #2f3a4d; border-radius:16px; padding:16px; display:flex; flex-wrap:wrap; gap:16px; align-items:stretch`)},h("div",{style:st(`flex:1.2 1 520px; min-width:0; background:${V.code}; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`)},h("div",{style:st("display:flex; align-items:center; gap:14px; padding:12px 16px; border-bottom:1px solid #243044")},h("div",{style:st("display:flex; gap:7px")},[0,1,2].map(i=>h("span",{key:i,style:st(`width:10px; height:10px; border-radius:50%; background:${V.border}`)}))),h("span",{style:st(`${MONO}; font-size:12px; color:${V.primary}`)},"ArticleTeaser.html")),h("div",{style:st(`padding:12px 0 14px; ${MONO}; font-size:13px; line-height:1.95; min-height:268px; opacity:${fade}`)},lines.map(ln=>h("div",{key:ln.n,style:st(`display:flex; background:${ln.on?"rgba(197,228,120,0.07)":"transparent"}`)},h("span",{style:st(`width:3px; flex:none; background:${ln.on?V.entity:"transparent"}`)}),h("span",{style:st(`width:30px; flex:none; text-align:right; padding-right:14px; color:${V.ln}`)},ln.n),h("span",{style:st("min-width:0; white-space:pre-wrap; word-break:break-all; padding-right:16px")},ln.toks.map((x,i)=>x.caret?h("span",{key:i,style:st(`display:inline-block; width:2px; height:1.15em; margin-left:1px; vertical-align:-0.2em; background:${V.primary}; opacity:${x.o}`)}):h("span",{key:i,style:st(`color:${x.c}`)},x.t)))))),h("div",{style:st("flex:1; position:relative; overflow:hidden; border-top:1px solid #243044; background:repeating-linear-gradient(45deg, #172030 0 8px, #1c2535 8px 16px); padding:34px 24px 28px; display:flex; justify-content:center; align-items:center")},h("div",{style:st(`position:relative; width:100%; max-width:430px; display:flex; gap:14px; background:${V.surface}; border:1.5px solid ${entityOn?V.entity:"#38455e"}; border-radius:10px; padding:12px; opacity:${clamp(ph/250)*(1-so)}; transform:translateY(${cardOff.toFixed(1)}px)`)},h("div",{style:st("position:absolute; top:-10px; left:12px; display:flex; gap:6px")},entityOn?h(Pill,{bg:V.entity},"article"):null,impOn?h(Pill,{bg:V.action},"impression"):null),h("div",{style:st("flex:none; width:112px; min-height:86px; border-radius:6px; overflow:hidden; position:relative; background:#2e3a50")},h(Thumb,{art})),h("div",{style:st("flex:1; min-width:0; display:flex; flex-direction:column; gap:4px")},h("span",{style:st(`align-self:flex-start; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; color:${V.fg2}; border-radius:3px; padding:0 3px; margin-left:-3px; outline:1.5px solid ${catOn?V.prop:"transparent"}; box-shadow:${catOn?propGlow:"none"}`)},art.kicker),h("span",{style:st("font-size:15px; font-weight:600; line-height:1.3; text-wrap:pretty")},h("span",{style:st(`border-radius:3px; outline:1.5px solid ${propsOn?V.prop:"transparent"}; outline-offset:1px; box-shadow:${propsOn?propGlow:"none"}`)},art.title)),h("div",{style:st("margin-top:auto; padding-top:6px; display:flex; align-items:center; gap:8px")},h("span",{style:st(`position:relative; font-size:13px; font-weight:500; color:${V.primary}; padding:1px 5px; margin-left:-5px; border-radius:5px; outline:1.5px solid ${actionOn?V.action:"transparent"}`)},"Open \u2192",h("span",{style:st(`position:absolute; left:${(22+(1-m)*190).toFixed(1)}px; top:${(11-(1-m)*80).toFixed(1)}px; width:22px; height:22px; margin:-11px 0 0 -11px; pointer-events:none; opacity:${curOp}`)},h("span",{style:st(`position:absolute; inset:0; border-radius:50%; border:2px solid ${V.entity}; opacity:${ph>=P.click?((1-rp)*.9).toFixed(3):0}; transform:scale(${(1+rp*1.8).toFixed(3)})`)}),h("span",{style:st(`position:absolute; inset:0; border-radius:50%; background:rgba(255,255,255,0.22); border:2px solid rgba(255,255,255,0.9); box-shadow:0 4px 14px rgba(0,0,0,0.4); transform:scale(${press?.8:1})`)})))))))),h("div",{style:st(`flex:1 1 400px; min-width:0; background:#1e2839; border:1px solid ${V.surface}; border-radius:12px; overflow:hidden; display:flex; flex-direction:column`)},h("div",{style:st(`display:flex; justify-content:space-between; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid ${V.surface}; ${MONO}; font-size:12px`)},h("span",{style:st(`color:${V.fg2}`)},h("span",{style:st(`color:${V.action}`)},"SELECT")," * ",h("span",{style:st(`color:${V.action}`)},"FROM")," ",h("span",{style:st(`color:${V.fg}`)},"walkerOS.events")),h("span",{style:st(`display:flex; align-items:center; gap:7px; color:${V.entity}`)},h("span",{style:st(`width:7px; height:7px; border-radius:50%; background:${V.entity}`)}),"live")),h("div",{style:st("flex:1; position:relative; min-height:300px")},h("div",{style:st(`position:absolute; inset:0; overflow-x:auto; overflow-y:hidden; ${MONO}; font-size:12px`)},h("div",{style:st("min-width:454px")},h("div",{style:st(`${grid}; background:#243044; border-bottom:1px solid ${V.border}`)},h("span",{style:st(`padding:8px 10px; ${cellR}; color:${V.comment}; font-size:11px; text-align:right`)},"Row"),h("span",{style:st(`padding:8px 10px; ${cellR}; display:flex; flex-direction:column; gap:1px`)},h("span",{style:st(`color:${V.fg}`)},"name"),h("span",{style:st(`color:${V.comment}; font-size:10px`)},"STRING")),h("span",{style:st("padding:8px 10px; display:flex; flex-direction:column; gap:1px")},h("span",{style:st(`color:${V.fg}`)},"data"),h("span",{style:st(`color:${V.comment}; font-size:10px`)},"JSON"))),rows.map(r=>h("div",{key:r.key,style:st(`${grid}; border-bottom:1px solid ${V.surface}; background:${r.bg}; opacity:${r.op}; transform:${r.ty}; line-height:1.4`)},h("span",{style:st(`padding:10px; ${cellR}; color:${V.comment}; text-align:right`)},r.n),h("span",{style:st(`padding:10px; ${cellR}; color:${V.fg}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`)},r.name),h("span",{style:st(`padding:10px; color:${V.punct}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis`)},'{"',h("span",{style:st(`color:${V.glob}`)},"title"),'":"',h("span",{style:st(`color:${V.str}`)},r.title),'","',h("span",{style:st(`color:${V.glob}`)},"category"),'":"',h("span",{style:st(`color:${V.str}`)},r.cat),'"}'))),skeletons.map(s=>h("div",{key:"s"+s.n,style:st(`${grid}; border-bottom:1px solid ${V.surface}; line-height:1.4`)},h("span",{style:st(`padding:10px; ${cellR}; color:${V.ln}; text-align:right`)},s.n),h("span",{style:st(`padding:10px; ${cellR}; display:flex; align-items:center`)},h("span",{style:st(`height:8px; width:75%; border-radius:4px; background:${V.surface}`)})),h("span",{style:st("padding:10px; display:flex; align-items:center")},h("span",{style:st(`height:8px; width:${s.w}; border-radius:4px; background:#253145`)})))))))))}const AC={kw:V.action,tag:V.tag,at:V.glob,p:V.punct,s:V.str,x:V.fg},TY={entity:[V.entity,"rgba(197,228,120,"],action:[V.action,"rgba(199,146,234,"],prop:[V.prop,"rgba(240,113,127,"],ctx:[V.ctx,"rgba(242,166,90,"],glob:[V.glob,"rgba(127,196,234,"]},at=(t,c,hl)=>({t,c,h:!!hl}),F=(step,name,kind)=>({step,file:!0,name,kind}),L=(step,toks,fx,type)=>({step,toks,fx,type});function buildItems(){const it=[F(1,"Link.tsx","atom"),L(1,[at("export const",AC.kw),at(" Link ",AC.x),at("= ({ href, children }) => (",AC.p)]),L(1,[at(" "),at("",AC.tag)],"action","action"),L(1,[at(" "),at("{children}",AC.p)]),L(1,[at(" "),at("",AC.tag)]),L(1,[at(");",AC.p)]),F(2,"ArticleTeaser.tsx","molecule"),L(2,[at("export const",AC.kw),at(" ArticleTeaser ",AC.x),at("= ({",AC.p)]),L(2,[at(" title, category, image, url, position,",AC.p)]),L(2,[at("}) => (",AC.p)]),L(2,[at(" "),at("",AC.tag)],"pos","prop"),L(2,[at(" "),at("",AC.tag)],"img"),L(2,[at(" "),at("",AC.tag),at("{category}",AC.p),at("",AC.tag)],"cat","prop"),L(2,[at(" "),at("",AC.tag),at("{title}",AC.p),at("",AC.tag)],"title","prop"),L(2,[at(" "),at("",AC.tag),at("Open \u2192",AC.x),at("",AC.tag)]),L(2,[at(" "),at("",AC.tag)]),L(2,[at(");",AC.p)])];return[[3,"HomeFeed.tsx","homefeed","stories","home"],[4,"RelatedStories.tsx","related","related","related"]].forEach(([s,f,ctx,arr,fx])=>it.push(F(s,f,"organism"),L(s,[at("",AC.tag)],fx,"ctx"),L(s,[at(" {"+arr+".map((s, i) => ",AC.p),at("",AC.tag),at(")}",AC.p)]),L(s,[at("",AC.tag)]))),[["HomePage.tsx","homepage","HomeFeed","gHome"],["ArticlePage.tsx","article","RelatedStories","gArt"]].forEach(([file,pt,org,fx])=>it.push(F(5,file,"template"),L(5,[at("",AC.tag)],fx,"glob"),L(5,[at(" "),at("<"+org+" />",AC.tag)]),L(5,[at("",AC.tag)]))),it}const ITEMS=buildItems(),HOME=[{kicker:"Tech",title:"Return of the Bug"},{kicker:"Tech",title:"The quiet return of RSS"},{kicker:"Food",title:"Why sourdough came back"}],RELATED=[{kicker:"Tech",title:"The quiet return of RSS"},{kicker:"Science",title:"A year on the ice shelf"},{kicker:"Culture",title:"Small venues, big nights"}],STEPS=[[1,"Atom"],[2,"Molecule"],[3,"HomeFeed"],[4,"RelatedStories"],[5,"Pages"]],CAPTIONS={1:"Atom. Link carries the click:open action wherever it is rendered.",2:"Molecule. ArticleTeaser makes each card an article entity and reads category and title from its elements.",3:"Organism. HomeFeed wraps three teasers and adds list:homefeed context to their events.",4:"Organism. RelatedStories reuses the same ArticleTeaser and adds list:related context.",5:"Pages. Each page template sets pagetype as a global, which is added to every event on that page."},aglow=rgba=>"0 0 10px 1px "+rgba+"0.5)";class ArticleTeaserTracking extends React.Component{constructor(p){super(p),this.state={n:p.initialStep?this.stepEnd(p.initialStep):0},this.codeRef=React.createRef()}get auto(){var _a;return(_a=this.props.autoplay)!=null?_a:!0}get speed(){var _a;return(_a=this.props.speed)!=null?_a:1}stepEnd(k){let last=ITEMS.findIndex(x=>x.step===k);for(;ITEMS[last+1]&&ITEMS[last+1].step===k;)last++;return last+1}componentDidMount(){const go=()=>this.schedule(800),el=this.codeRef.current;if(!el||!window.IntersectionObserver)return go();this.io=new IntersectionObserver(es=>{es.some(e=>e.isIntersecting)&&(this.io.disconnect(),go())},{threshold:.35}),this.io.observe(el)}componentWillUnmount(){clearTimeout(this.tm),clearTimeout(this.st),this.io&&this.io.disconnect()}componentDidUpdate(pp){pp.autoplay!==this.props.autoplay&&this.auto&&this.schedule(500)}scrollToCurrent(n){clearTimeout(this.st),this.st=setTimeout(()=>{const el=this.codeRef.current;if(!el)return;const tgt=el.querySelector('[data-ln="'+(n-1)+'"]');if(!tgt){el.scrollTo({top:0,behavior:"smooth"});return}const y=tgt.getBoundingClientRect().top-el.getBoundingClientRect().top+el.scrollTop;el.scrollTo({top:Math.max(0,y-el.clientHeight/2),behavior:"smooth"})},400)}setN(n){this.setState({n}),this.scrollToCurrent(n)}schedule(ms){clearTimeout(this.tm),this.tm=setTimeout(()=>this.advance(),ms/this.speed)}advance(){if(!this.auto)return;const n=this.state.n;if(n>=ITEMS.length)return;const s=ITEMS[n].step;if(ITEMS[n].file&&(n===0||ITEMS[n-1].step!==s)){this.setN(n+1),this.schedule(1300);return}let j=n;for(;jx.step===k);this.auto?(this.setN(first),this.schedule(250)):this.setN(this.stepEnd(k))}render(){const n=Math.min(this.state.n,ITEMS.length),visStep=n?ITEMS[n-1].step:0,cur=Math.max(1,visStep),seen=new Set(ITEMS.slice(0,n).map(x=>x.fx).filter(Boolean)),f={};["action","entity","pos","img","cat","title","home","related","gHome","gArt"].forEach(x=>f[x]=seen.has(x));let num=0,firstFile=!0;const lines=ITEMS.map((it,i)=>{const vis=it.step<=visStep,on=ih("span",{key:j,style:st(`color:${k.c||AC.p}; background:${k.h&&ty&&on?ty[1]+"0.16)":"transparent"}; border-radius:3px; transition:background .35s`)},k.t)))))}),card=(c,i)=>h("div",{key:i,style:st(`position:relative; min-height:224px; border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:8px; border:1.5px ${f.entity?"solid":"dashed"} ${f.entity?V.entity:"#2b374c"}; background:${f.entity?V.surface:"transparent"}; transition:all .45s ease`)},h("span",{style:st("position:absolute; top:-10px; left:10px; display:flex; gap:4px")},h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${V.entity}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${f.entity?1:0}; transition:opacity .4s`)},"article"),h("span",{style:st(`padding:0 6px; border-radius:4px; border:1.5px solid ${V.prop}; background:#1a2232; color:${V.prop}; ${MONO}; font-size:10.5px; line-height:16px; opacity:${f.pos?1:0}; transition:opacity .4s`)},i+1)),h("div",{style:st(`height:86px; flex:none; border-radius:6px; overflow:hidden; position:relative; background:repeating-linear-gradient(45deg, #2e3a50 0 6px, #323f56 6px 12px); display:flex; align-items:center; justify-content:center; ${MONO}; font-size:10.5px; color:${V.comment}; opacity:${f.img?1:0}; transition:opacity .45s`)},"photo"),h("span",{style:st(`align-self:flex-start; padding:1px 6px; border-radius:4px; background:#334058; color:#c3c9d9; ${MONO}; font-size:10.5px; letter-spacing:0.04em; text-transform:uppercase; opacity:${f.cat?1:0}; outline:1.5px solid ${f.cat?V.prop:"transparent"}; box-shadow:${f.cat?aglow(TY.prop[1]):"none"}; transition:all .45s`)},c.kicker),h("span",{style:st(`align-self:flex-start; font-size:14px; font-weight:600; line-height:1.3; text-wrap:pretty; border-radius:3px; outline:1.5px solid ${f.title?V.prop:"transparent"}; outline-offset:2px; box-shadow:${f.title?aglow(TY.prop[1]):"none"}; opacity:${f.title?1:0}; transition:all .45s`)},c.title),h("span",{style:st(`margin-top:auto; align-self:flex-start; padding:2px 6px; margin-left:-6px; border-radius:4px; font-size:13px; font-weight:500; color:${V.primary}; outline:1.5px solid ${f.action?V.action:"transparent"}; box-shadow:${f.action?aglow(TY.action[1]):"none"}; opacity:${f.action?1:0}; transition:all .45s`)},"Open \u2192")),pill=(bg,txt,op)=>h("span",{style:st(`padding:1px 7px; border-radius:4px; background:${bg}; color:#1a2232; ${MONO}; font-size:10.5px; font-weight:500; line-height:17px; opacity:${op}; transition:opacity .5s`)},txt),group=(on,label,listOn,header,ctx,cards)=>h("div",{style:st(`position:relative; border-radius:16px; padding:12px; border:1.5px dashed ${on?V.glob:"transparent"}; background:${on?"rgba(127,196,234,0.04)":"transparent"}; transition:all .5s`)},h("span",{style:st("position:absolute; top:-10px; left:14px")},pill(V.glob,label,on?1:0)),h("div",{style:st(`border-radius:12px; padding:14px; display:flex; flex-direction:column; gap:16px; border:1.5px solid ${listOn?V.ctx:"#243044"}; background:${listOn?"rgba(242,166,90,0.05)":"transparent"}; transition:all .5s`)},h("div",{style:st(`display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; min-height:19px; opacity:${listOn?1:0}; transition:opacity .5s`)},header,pill(V.ctx,ctx,1)),h("div",{style:st("display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:12px")},cards.map(card)))),legend=[["entity",V.entity],["action",V.action],["property",V.prop],["context",V.ctx],["globals",V.glob]];return h("div",{className:"wos-viz",style:st(`${SANS}; color:${V.fg}; width:100%; background:${V.bg}; border-radius:16px; padding:24px; display:flex; flex-direction:column; gap:18px`)},h("div",{style:st("display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px 20px")},h("div",{style:st("display:flex; flex-wrap:wrap; gap:8px")},STEPS.map(([k,label])=>{const a=k===cur,done=kthis.jump(k),"aria-pressed":a,style:st(`display:flex; align-items:center; gap:8px; padding:6px 12px 6px 6px; border-radius:999px; border:1px solid ${a?V.entity:V.border}; background:${a?"rgba(197,228,120,0.10)":"transparent"}; color:${a?V.fg:V.fg2}; ${SANS}; font-size:13px; font-weight:500; cursor:pointer; transition:all .3s`)},h("span",{style:st(`width:20px; height:20px; border-radius:50%; background:${a?V.entity:done?V.comment:V.border}; color:#1a2232; font-weight:600; font-size:11px; line-height:20px; text-align:center; transition:all .3s`)},k),h("span",null,label))})),h("div",{style:st(`display:flex; flex-wrap:wrap; gap:14px; ${MONO}; font-size:11px; color:${V.fg2}`)},legend.map(([n2,c])=>h("span",{key:n2,style:st("display:flex; align-items:center; gap:6px")},h("span",{style:st(`width:9px; height:9px; border-radius:2px; background:${c}`)}),n2)))),h("div",{style:st("font-size:15px; line-height:1.5; color:#c3c9d9; min-height:23px; text-wrap:pretty")},CAPTIONS[cur]),h("div",{style:st("display:flex; flex-wrap:wrap; gap:16px; align-items:stretch")},h("div",{style:st(`flex:1 1 380px; min-width:0; align-self:flex-start; position:sticky; top:16px; background:${V.code}; border:1px solid ${V.border}; border-radius:12px; overflow:hidden; padding:4px 0 10px`)},h("div",{ref:this.codeRef,style:st(`position:relative; overflow:auto; max-height:min(640px, calc(100vh - 48px)); ${MONO}; font-size:12px`)},lines)),h("div",{style:st("flex:1.1 1 420px; min-width:0; display:flex; flex-direction:column; gap:24px; padding-top:10px; align-self:flex-start")},group(f.gHome,"pagetype:homepage",f.home,h("span",{style:st(`${MONO}; font-size:12px; color:${V.fg2}`)}," \xB7 homepage"),"list:homefeed",HOME),group(f.gArt,"pagetype:article",f.related,h("span",{style:st("display:flex; align-items:center; gap:10px")},h("span",{style:st(`${MONO}; font-size:12px; color:${V.fg2}`)}," \xB7 article page"),h("span",{style:st("font-size:14px; font-weight:600")},"Related stories")),"list:related",RELATED))))}}Object.assign(window.WalkerOS=window.WalkerOS||{},{HeroTaggingViz,ArticleTeaserTracking,DestinationMappingViz});})(); diff --git a/apps/explorer/design/components/src/viz.jsx b/apps/explorer/design/components/src/viz.jsx index 07e717765..8fd2d920e 100644 --- a/apps/explorer/design/components/src/viz.jsx +++ b/apps/explorer/design/components/src/viz.jsx @@ -79,9 +79,9 @@ const MEVENTS = [ ] }, ]; const MSHORT = { - product: "elb('product view', { id: 'ers', price: 420, currency: 'EUR' })", - lead: "elb('lead submit', { type: 'demo', value: 2500, currency: 'EUR' })", - order: "elb('order complete', { id: '0rd3r1d', value: 462, currency: 'EUR' })", + product: "elb({ name: 'product view', data: { id: 'ers', price: 420, currency: 'EUR' } })", + lead: "elb({ name: 'lead submit', data: { type: 'demo', value: 2500, currency: 'EUR' } })", + order: "elb({ name: 'order complete', data: { id: '0rd3r1d', value: 462, currency: 'EUR' } })", }; function DestinationMappingViz(props) { diff --git a/apps/explorer/src/design/components/viz/__tests__/format.test.ts b/apps/explorer/src/design/components/viz/__tests__/format.test.ts index 8b476e0e5..58dd4abab 100644 --- a/apps/explorer/src/design/components/viz/__tests__/format.test.ts +++ b/apps/explorer/src/design/components/viz/__tests__/format.test.ts @@ -77,14 +77,16 @@ describe('formatCall', () => { }); describe('formatElb', () => { - it('prints a flat event as elb(name, data)', () => { + it('prints a flat event as a partial event', () => { expect( formatElb({ entity: 'product', action: 'view', data: { id: 'ers', price: 420, currency: 'EUR' }, }), - ).toBe("elb('product view', { id: 'ers', price: 420, currency: 'EUR' })"); + ).toBe( + "elb({ name: 'product view', data: { id: 'ers', price: 420, currency: 'EUR' } })", + ); }); it('prints an event with nested entities as an object, nested abbreviated', () => { diff --git a/apps/explorer/src/design/components/viz/parts/format.ts b/apps/explorer/src/design/components/viz/parts/format.ts index af9392d73..ba696cf23 100644 --- a/apps/explorer/src/design/components/viz/parts/format.ts +++ b/apps/explorer/src/design/components/viz/parts/format.ts @@ -94,15 +94,19 @@ export function formatCall(record: CallRecord): string[] { ]; } -/** The `elb()` call that sends `event`; nested entities are abbreviated. */ +/** The `elb()` call that sends `event`, as a partial event; nested entities are abbreviated. */ export function formatElb(event: ElbShape): string { - const name = literal(`${event.entity} ${event.action}`); - const data = literal(event.data); - if (!event.nested?.length) return `elb(${name}, ${data})`; - const nested = event.nested.map( - (entity) => `{ entity: ${literal(entity.entity)}, … }`, - ); - return `elb({ name: ${name}, data: ${data}, nested: [${nested.join(', ')}] })`; + const fields = [ + `name: ${literal(`${event.entity} ${event.action}`)}`, + `data: ${literal(event.data)}`, + ]; + if (event.nested?.length) { + const nested = event.nested.map( + (entity) => `{ entity: ${literal(entity.entity)}, … }`, + ); + fields.push(`nested: [${nested.join(', ')}]`); + } + return `elb({ ${fields.join(', ')} })`; } const TOKEN = /"[^"]*"|'[^']*'|\d+(?:\.\d+)?|[A-Za-z_]\w*|\s+|./g;